From 3b81505aa9dc7d11b9bc2011a836847130339b49 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Mon, 10 Aug 2026 14:39:37 +0300 Subject: [PATCH] feat: mark unsupported insecure profiles --- .../src/main/java/com/v2ray/ang/AppConfig.kt | 1 + .../ang/core/XrayOutboundCompatibility.kt | 149 +++++++++++++++ .../v2ray/ang/dto/entities/ServersCache.kt | 2 + .../ang/service/SubscriptionUpdateService.kt | 94 +++++----- .../com/v2ray/ang/ui/main/MainRepository.kt | 4 + .../com/v2ray/ang/ui/main/MainServerPager.kt | 21 ++- .../com/v2ray/ang/ui/main/MainServiceEvent.kt | 1 + .../com/v2ray/ang/ui/main/MainViewModel.kt | 29 +++ V2rayNG/app/src/main/res/values/strings.xml | 1 + .../ang/core/XrayOutboundCompatibilityTest.kt | 172 ++++++++++++++++++ 10 files changed, 431 insertions(+), 43 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/core/XrayOutboundCompatibility.kt create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/core/XrayOutboundCompatibilityTest.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt index 8b8b0fedaa..7eaee535f6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt @@ -188,6 +188,7 @@ object AppConfig { const val MSG_SUB_UPDATE_START = 8 const val MSG_SUB_UPDATE_CANCEL = 81 + const val MSG_SUB_UPDATE_DATA_CHANGED = 82 /** Notification channel IDs and names. */ // Use a new ID because Android does not let an app raise an existing channel's importance. diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/core/XrayOutboundCompatibility.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/core/XrayOutboundCompatibility.kt new file mode 100644 index 0000000000..7053b568c0 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/core/XrayOutboundCompatibility.kt @@ -0,0 +1,149 @@ +package com.v2ray.ang.core + +import com.v2ray.ang.AppConfig +import com.v2ray.ang.dto.entities.ProfileItem +import com.v2ray.ang.enums.EConfigType +import java.net.InetAddress + +/** + * Mirrors the insecure-outbound validation in the Xray-core revision pinned by + * AndroidLibXrayLite (5ca6f4b7d4dc). + */ +internal object XrayOutboundCompatibility { + private val tlsProfileTypes = setOf( + EConfigType.VMESS, + EConfigType.VLESS, + EConfigType.SHADOWSOCKS, + EConfigType.TROJAN, + EConfigType.HYSTERIA2, + ) + + private val privateIpPrefixes = listOf( + IpPrefix("0.0.0.0", 8), + IpPrefix("10.0.0.0", 8), + IpPrefix("100.64.0.0", 10), + IpPrefix("127.0.0.0", 8), + IpPrefix("169.254.0.0", 16), + IpPrefix("172.16.0.0", 12), + IpPrefix("192.0.0.0", 24), + IpPrefix("192.0.2.0", 24), + IpPrefix("192.88.99.0", 24), + IpPrefix("192.168.0.0", 16), + IpPrefix("198.18.0.0", 15), + IpPrefix("198.51.100.0", 24), + IpPrefix("203.0.113.0", 24), + IpPrefix("224.0.0.0", 3), + IpPrefix("::", 127), + IpPrefix("fc00::", 7), + IpPrefix("fe80::", 10), + IpPrefix("ff00::", 8), + ) + + private val privateDomainSuffixes = setOf( + "lan", + "localdomain", + "example", + "invalid", + "localhost", + "test", + "local", + "home.arpa", + "internal", + ) + + private val dotlessPrivateDomain = Regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$") + + fun isDeprecated(profile: ProfileItem): Boolean { + if (usesRemovedAllowInsecure(profile)) { + return true + } + + if (hasTransportSecurity(profile) || !requiresTransportSecurity(profile.server)) { + return false + } + + return when (profile.configType) { + EConfigType.VLESS -> profile.method.isNullOrEmpty() || profile.method == "none" + EConfigType.TROJAN -> true + else -> false + } + } + + /** Mirrors the value emitted by CoreOutboundBuilder.populateTlsSettings. */ + private fun usesRemovedAllowInsecure(profile: ProfileItem): Boolean = + profile.configType in tlsProfileTypes && + profile.security == AppConfig.TLS && + profile.insecure == true && + profile.pinnedCA256.isNullOrEmpty() + + private fun hasTransportSecurity(profile: ProfileItem): Boolean = + profile.security.equals(AppConfig.TLS, ignoreCase = true) || + profile.security.equals(AppConfig.REALITY, ignoreCase = true) + + private fun requiresTransportSecurity(server: String?): Boolean { + if (server.isNullOrEmpty()) return false + val address = normalizeAddress(server) + + parseIpLiteral(address)?.let { ip -> + return privateIpPrefixes.none { it.matches(ip) } + } + + val domain = address.lowercase().removeSuffix(".") + val isPrivateDomain = dotlessPrivateDomain.matches(domain) || + privateDomainSuffixes.any { suffix -> + domain == suffix || domain.endsWith(".$suffix") + } + return !isPrivateDomain + } + + /** Mirrors Xray's bracket removal and conditional whitespace trimming. */ + private fun normalizeAddress(server: String): String { + var address = server + if (address.startsWith('[') && address.endsWith(']')) { + address = address.substring(1, address.length - 1) + } + if (address.isNotEmpty() && (!address.first().isAsciiAlphaNumeric() || !address.last().isAsciiAlphaNumeric())) { + address = address.trim() + } + return address + } + + private fun Char.isAsciiAlphaNumeric(): Boolean = + this in '0'..'9' || this in 'a'..'z' || this in 'A'..'Z' + + private fun parseIpLiteral(address: String): ByteArray? { + if (':' in address) { + return runCatching { InetAddress.getByName(address).address }.getOrNull() + } + + val octets = address.split('.') + if (octets.size != 4) return null + val bytes = ByteArray(4) + octets.forEachIndexed { index, octet -> + if (octet.isEmpty() || octet.any { !it.isDigit() }) return null + if (octet.length > 1 && octet.startsWith('0')) return null + val value = octet.toIntOrNull()?.takeIf { it in 0..255 } ?: return null + bytes[index] = value.toByte() + } + return bytes + } + + private class IpPrefix(address: String, private val prefixLength: Int) { + private val addressBytes = checkNotNull(parseIpLiteral(address)) + + fun matches(candidate: ByteArray): Boolean { + if (candidate.size != addressBytes.size) return false + + val fullBytes = prefixLength / Byte.SIZE_BITS + for (index in 0 until fullBytes) { + if (candidate[index] != addressBytes[index]) return false + } + + val remainingBits = prefixLength % Byte.SIZE_BITS + if (remainingBits == 0) return true + val mask = (0xff shl (Byte.SIZE_BITS - remainingBits)) and 0xff + return (candidate[fullBytes].toInt() and mask) == + (addressBytes[fullBytes].toInt() and mask) + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt index 05df615189..4a9023b832 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt @@ -3,6 +3,8 @@ package com.v2ray.ang.dto.entities data class ServersCache( val guid: String, val profile: ProfileItem, + /** Derived once for this profile snapshot and reused by list recompositions. */ + val isDeprecated: Boolean, val testDelayMillis: Long = 0L, val testDelayString: String = "", ) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt index 88604bf2a4..0f9a8acaf5 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt @@ -15,6 +15,7 @@ import com.v2ray.ang.extension.serializable import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AppLocaleManager import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.helper.MessageHelper import com.v2ray.ang.helper.NotificationHelper import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.CompletableDeferred @@ -126,37 +127,45 @@ class SubscriptionUpdateService : Service() { content = getString(R.string.subscription_update_updating, subItem.remarks) ) - if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) { - AngConfigManager.updateConfigViaSub(sub) - } - - if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) { - testSubscriptionServers(sub) + var dataChanged = false + try { + if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) { + dataChanged = AngConfigManager.updateConfigViaSub(sub).configCount > 0 + } - if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) { - LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}") - showNotification( - context = this, - titleResId = R.string.title_del_invalid_config, - content = subItem.remarks - ) - AngConfigManager.removeInvalidServer(subId) + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) { + val testedServers = testSubscriptionServers(sub) + dataChanged = dataChanged || testedServers + + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) { + LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}") + showNotification( + context = this, + titleResId = R.string.title_del_invalid_config, + content = subItem.remarks + ) + AngConfigManager.removeInvalidServer(subId) + } + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) { + LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}") + showNotification( + context = this, + titleResId = R.string.title_sort_by_test_results, + content = subItem.remarks + ) + AngConfigManager.sortByTestResultsForSub(subId) + } } - if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) { - LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}") - showNotification( - context = this, - titleResId = R.string.title_sort_by_test_results, - content = subItem.remarks - ) - AngConfigManager.sortByTestResultsForSub(subId) + + LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}") + } finally { + if (dataChanged) { + MessageHelper.sendMsg2UI(this, AppConfig.MSG_SUB_UPDATE_DATA_CHANGED, subId) } } - - LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}") } - private suspend fun testSubscriptionServers(sub: SubscriptionCache) { + private suspend fun testSubscriptionServers(sub: SubscriptionCache): Boolean { val subId = sub.guid LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: starting test phase for ${sub.subscription.remarks}") showNotification( @@ -166,24 +175,25 @@ class SubscriptionUpdateService : Service() { ) val guids = MmkvManager.decodeServerList(subId) - if (guids.isNotEmpty()) { - val deferred = CompletableDeferred() - lateinit var worker: RealPingWorkerService - worker = RealPingWorkerService( - context = this, - guids = guids, - onEvent = { event -> - handleWorkerEvent(event, sub.subscription.remarks) { - activeWorkers.remove(worker) - deferred.complete(Unit) - } + if (guids.isEmpty()) return false + + val deferred = CompletableDeferred() + lateinit var worker: RealPingWorkerService + worker = RealPingWorkerService( + context = this, + guids = guids, + onEvent = { event -> + handleWorkerEvent(event, sub.subscription.remarks) { + activeWorkers.remove(worker) + deferred.complete(Unit) } - ) - activeWorkers.add(worker) - worker.start() - deferred.await() - LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}") - } + } + ) + activeWorkers.add(worker) + worker.start() + deferred.await() + LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}") + return true } private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt index d1e0e0fb7b..e5c23e59bf 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt @@ -70,6 +70,10 @@ class MainRepository( safeIntent.getStringExtra("content") ) + AppConfig.MSG_SUB_UPDATE_DATA_CHANGED -> safeIntent.getStringExtra("content") + ?.takeIf { it.isNotBlank() } + ?.let(MainServiceEvent::SubscriptionDataChanged) + else -> null } event?.let { _mainServiceEvent.tryEmit(it) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt index dad6ef8364..2dd805fd92 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServerPager.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextOverflow @@ -252,6 +253,7 @@ private fun ServerItemRow( testResult = serverCache.testDelayString, testDelayMillis = serverCache.testDelayMillis, isSelected = serverCache.guid == selectedGuid, + isDeprecated = serverCache.isDeprecated, subscriptionRemarks = subRemarks, doubleColumnDisplay = false, onClick = { onSelectServer(serverCache.guid) }, @@ -286,6 +288,7 @@ private fun ServerItemColumn( testResult = serverCache.testDelayString, testDelayMillis = serverCache.testDelayMillis, isSelected = serverCache.guid == selectedGuid, + isDeprecated = serverCache.isDeprecated, subscriptionRemarks = subRemarks, doubleColumnDisplay = doubleColumnDisplay, onClick = { onSelectServer(serverCache.guid) }, @@ -306,6 +309,7 @@ fun ServerListItem( testResult: String, testDelayMillis: Long, isSelected: Boolean, + isDeprecated: Boolean, subscriptionRemarks: String, doubleColumnDisplay: Boolean, onClick: () -> Unit, @@ -320,6 +324,11 @@ fun ServerListItem( modifier = modifier .fillMaxWidth() .height(IntrinsicSize.Min) + .then( + if (isDeprecated) { + Modifier.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.55f)) + } else Modifier + ) .clickable(onClick = onClick) .then(dragModifier) ) { @@ -375,7 +384,17 @@ fun ServerListItem( } Spacer(modifier = Modifier.height(6.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = if (isDeprecated) { + stringResource(R.string.profile_deprecated) + } else { + typeDescription + }, + style = MaterialTheme.typography.bodySmall, + color = if (isDeprecated) MaterialTheme.colorScheme.error else colorConfigType, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServiceEvent.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServiceEvent.kt index 105092c7ac..553044cf3a 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServiceEvent.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainServiceEvent.kt @@ -10,4 +10,5 @@ sealed class MainServiceEvent { data object MeasureConfigSuccess : MainServiceEvent() data class MeasureConfigNotify(val progress: String) : MainServiceEvent() data class MeasureConfigFinish(val finishedCount: String?) : MainServiceEvent() + data class SubscriptionDataChanged(val subscriptionId: String) : MainServiceEvent() } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt index c1114af800..8b098af534 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.v2ray.ang.AppConfig import com.v2ray.ang.R +import com.v2ray.ang.core.XrayOutboundCompatibility import com.v2ray.ang.dto.GroupMapItem import com.v2ray.ang.dto.LocateTarget import com.v2ray.ang.dto.TestServiceMessage @@ -142,6 +143,33 @@ class MainViewModel( is MainServiceEvent.MeasureConfigFinish -> { onTestsFinished() } + + is MainServiceEvent.SubscriptionDataChanged -> { + refreshChangedSubscription(event.subscriptionId) + } + } + } + + private fun refreshChangedSubscription(subscriptionId: String) { + viewModelScope.launch(ioDispatcher) { + val visibleGroupIds = uiState.value.groups.mapTo(HashSet()) { it.id } + val affectedGroupIds = buildList { + if (subscriptionId in visibleGroupIds) add(subscriptionId) + if ("" in visibleGroupIds) add("") + } + affectedGroupIds.forEach { groupId -> + val loadMutex = groupLoadMutexes.computeIfAbsent(groupId) { Mutex() } + // Do not let an in-flight preload restore the stale snapshot after invalidation. + loadMutex.withLock { + cacheMutex.withLock { groupDataCache.remove(groupId) } + } + } + + val selectedGroupId = uiState.value.selectedGroupId + if (selectedGroupId in affectedGroupIds) { + updateGroupUi(selectedGroupId, loadGroup(selectedGroupId)) + } + _uiState.update { it.copy(selectedGuid = dataSource.getSelectServer()) } } } @@ -235,6 +263,7 @@ class MainViewModel( ServersCache( guid = guid, profile = profile.copy(), + isDeprecated = XrayOutboundCompatibility.isDeprecated(profile), testDelayMillis = affiliation?.testDelayMillis ?: 0L, testDelayString = affiliation?.getTestDelayString().orEmpty() ) diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index bf8c2ec7fd..3825fd6aef 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -133,6 +133,7 @@ Enable Browser Dialer Only supports xhttp (packet-up) and ws outbound; TLS-related settings may be ignored or conflict Current node uses an unencrypted connection. Your communication may be directly viewed by network intermediate facilities controlled by authoritarian governments.\nFor security reasons, such nodes cannot be connected via Xray core version 26.2.6+.\nIf it is a self-built node, please enable security encryption such as TLS, or pin the certificate pinnedCA256.\nIf it is a provider node, please contact the service provider to complete the technical upgrade. If the service provider refuses to cooperate, it is recommended to switch to a provider that pays more attention to user security.\nFor more information, please visit https://github.com/2dust/v2rayN/discussions/9460 + DEPRECATED Fetch certificate fingerprint Certificate fingerprint fetched successfully Failed to fetch certificate fingerprint diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/core/XrayOutboundCompatibilityTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/core/XrayOutboundCompatibilityTest.kt new file mode 100644 index 0000000000..a836ed3e0c --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/core/XrayOutboundCompatibilityTest.kt @@ -0,0 +1,172 @@ +package com.v2ray.ang.core + +import com.v2ray.ang.AppConfig +import com.v2ray.ang.dto.entities.ProfileItem +import com.v2ray.ang.enums.EConfigType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class XrayOutboundCompatibilityTest { + private val validVlessEncryption = + "mlkem768x25519plus.native.0rtt.2PcBa3Yz0zBdt4p8-PkJMzx9hIj2Ve-UmrnmZRPnpRk" + + @Test + fun unencryptedVlessToPublicEndpointIsDeprecated() { + assertTrue(isDeprecated(EConfigType.VLESS, server = "example.com")) + assertTrue(isDeprecated(EConfigType.VLESS, server = "8.8.8.8", method = "none")) + assertTrue(isDeprecated(EConfigType.VLESS, server = "2001:4860:4860::8888")) + } + + @Test + fun vlessTransportOrProtocolEncryptionRemainsSupported() { + assertFalse(isDeprecated(EConfigType.VLESS, server = "example.com", security = AppConfig.TLS)) + assertFalse(isDeprecated(EConfigType.VLESS, server = "example.com", security = AppConfig.REALITY)) + assertFalse( + isDeprecated( + EConfigType.VLESS, + server = "example.com", + method = validVlessEncryption, + ) + ) + } + + @Test + fun removedTlsAllowInsecureIsDeprecatedForGeneratedTlsOutbounds() { + listOf( + EConfigType.VMESS, + EConfigType.VLESS, + EConfigType.SHADOWSOCKS, + EConfigType.TROJAN, + EConfigType.HYSTERIA2, + ).forEach { type -> + val profile = profile(type, server = "example.com", security = AppConfig.TLS) + profile.insecure = true + + assertTrue(type.name, XrayOutboundCompatibility.isDeprecated(profile)) + } + } + + @Test + fun pinnedCertificatePreventsAllowInsecureFromBeingGenerated() { + val profile = profile(EConfigType.VLESS, server = "example.com", security = AppConfig.TLS) + profile.insecure = true + profile.pinnedCA256 = "00".repeat(32) + + assertFalse(XrayOutboundCompatibility.isDeprecated(profile)) + } + + @Test + fun removedTlsAllowInsecureIsRejectedEvenForPrivateEndpoints() { + val profile = profile(EConfigType.VLESS, server = "192.168.1.1", security = AppConfig.TLS) + profile.insecure = true + + assertTrue(XrayOutboundCompatibility.isDeprecated(profile)) + } + + @Test + fun allowInsecureFlagIsIgnoredWithoutGeneratedTlsSettings() { + val profiles = listOf( + profile(EConfigType.VLESS, server = "example.com", security = AppConfig.REALITY), + profile(EConfigType.VLESS, server = "example.com", method = validVlessEncryption), + profile(EConfigType.SOCKS, server = "example.com", security = AppConfig.TLS), + ) + profiles.forEach { it.insecure = true } + + profiles.forEach { profile -> + assertFalse(profile.configType.name, XrayOutboundCompatibility.isDeprecated(profile)) + } + } + + @Test + fun unencryptedTrojanToPublicEndpointIsDeprecated() { + assertTrue(isDeprecated(EConfigType.TROJAN, server = "example.com")) + assertFalse(isDeprecated(EConfigType.TROJAN, server = "example.com", security = AppConfig.TLS)) + } + + @Test + fun xrayPrivateIpv4RangesRemainSupported() { + val privateAddresses = listOf( + "0.0.0.1", + "10.255.255.255", + "100.64.0.1", + "127.0.0.1", + "169.254.1.1", + "172.31.255.255", + "192.0.0.1", + "192.0.2.1", + "192.88.99.1", + "192.168.1.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "255.255.255.255", + ) + + privateAddresses.forEach { server -> + assertFalse(server, isDeprecated(EConfigType.VLESS, server = server)) + } + assertTrue(isDeprecated(EConfigType.VLESS, server = "100.128.0.1")) + assertTrue(isDeprecated(EConfigType.VLESS, server = "192.0.3.1")) + assertTrue(isDeprecated(EConfigType.VLESS, server = "010.0.0.1")) + assertFalse(isDeprecated(EConfigType.VLESS, server = " 10.0.0.1 ")) + } + + @Test + fun xrayPrivateIpv6RangesRemainSupported() { + listOf("::", "::1", "fc00::1", "fdff::1", "fe80::1", "ff02::1").forEach { server -> + assertFalse(server, isDeprecated(EConfigType.TROJAN, server = server)) + } + assertTrue(isDeprecated(EConfigType.TROJAN, server = "2001:db8::1")) + } + + @Test + fun xrayPrivateDomainsRemainSupported() { + val privateDomains = listOf( + "router", + "LAN", + "host.localdomain", + "node.example", + "service.invalid", + "localhost", + "proxy.test", + "printer.local", + "gateway.home.arpa", + "service.internal.", + ) + + privateDomains.forEach { server -> + assertFalse(server, isDeprecated(EConfigType.VLESS, server = server)) + } + assertTrue(isDeprecated(EConfigType.VLESS, server = "example.com")) + assertTrue(isDeprecated(EConfigType.VLESS, server = "internal.example.com")) + } + + @Test + fun unrelatedProtocolsAreNotMarked() { + assertFalse(isDeprecated(EConfigType.VMESS, server = "example.com")) + assertFalse(isDeprecated(EConfigType.SHADOWSOCKS, server = "example.com")) + assertFalse(isDeprecated(EConfigType.WIREGUARD, server = "example.com")) + } + + private fun isDeprecated( + type: EConfigType, + server: String, + security: String? = null, + method: String? = null, + ): Boolean = XrayOutboundCompatibility.isDeprecated( + profile(type, server, security, method) + ) + + private fun profile( + type: EConfigType, + server: String, + security: String? = null, + method: String? = null, + ) = ProfileItem( + configType = type, + server = server, + security = security, + method = method, + ) +}