fix: 404 в TUN active probe + уборка debug информации из UI. #37
Some checks are pending
CI / build (push) Waiting to run

This commit is contained in:
xtclovver 2026-04-21 20:28:36 +03:00
parent 0219391b22
commit fbf28868e3
12 changed files with 285 additions and 123 deletions

View file

@ -601,7 +601,6 @@ object BypassChecker {
)
}
val usedTransportOnlyFallback = addTransportOnlyFinding(context, result, findings)
addDebugTunProbeFindings(context, result, findings)
if (result.activeNetworkIsVpn == false) {
when {
@ -843,40 +842,6 @@ object BypassChecker {
return true
}
private fun addDebugTunProbeFindings(
context: Context,
result: UnderlyingNetworkProber.ProbeResult,
findings: MutableList<Finding>,
) {
val diagnostics = result.tunProbeDiagnostics ?: return
diagnostics.vpnPath?.let { vpnPath ->
findings.add(
Finding(
description = TunProbeDiagnosticsFormatter.formatUiSummary(
context = context,
pathLabel = context.getString(R.string.checker_tun_probe_path_vpn),
modeOverride = diagnostics.modeOverride,
path = vpnPath,
),
isInformational = true,
),
)
}
diagnostics.underlyingPath?.let { underlyingPath ->
findings.add(
Finding(
description = TunProbeDiagnosticsFormatter.formatUiSummary(
context = context,
pathLabel = context.getString(R.string.checker_tun_probe_path_underlying),
modeOverride = diagnostics.modeOverride,
path = underlyingPath,
),
isInformational = true,
),
)
}
}
private fun resolveProxyOwner(context: Context, proxyEndpoint: ProxyEndpoint): ProxyOwnerMatch {
val listeners = LocalSocketInspector.collect(context, protocols = setOf("tcp", "tcp6"))
return matchProxyOwner(proxyEndpoint, listeners)

View file

@ -7,7 +7,6 @@ import android.net.Proxy
import android.net.ProxyInfo
import android.os.Build
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.TunProbeDiagnosticsFormatter
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
@ -80,7 +79,6 @@ object DirectSignsChecker {
tunActiveProbeResult
?.takeIf { it.vpnActive }
?.let { result ->
addDebugTunProbeFinding(context, result, findings)
val tunActiveProbeOutcome = reportTunActiveProbe(context, result, findings, evidence)
detected = detected || tunActiveProbeOutcome.detected
needsReview = needsReview || tunActiveProbeOutcome.needsReview
@ -693,16 +691,15 @@ object DirectSignsChecker {
continue
}
val comparison = target.comparison
val transportOnly = comparison?.usedCurlCompatibleFallback() == true &&
comparison.curlCompatible.transportDiagnostics.resolveStrategy != TunProbeResolveStrategy.KOTLIN_INJECTED
findings.add(
Finding(
description = context.getString(
when {
transportOnly -> R.string.checker_bypass_tun_probe_success_transport_only
comparison?.usedCurlCompatibleFallback() == true -> R.string.checker_bypass_tun_probe_success_curl_compatible
else -> R.string.checker_bypass_tun_probe_success
if (comparison?.usedCurlCompatibleFallback() == true) {
R.string.checker_bypass_tun_probe_success_proxy_target
} else {
R.string.checker_bypass_tun_probe_success_direct_target
},
targetGroupLabel(context, target.targetGroup),
vpnIp,
),
isInformational = true,
@ -712,6 +709,8 @@ object DirectSignsChecker {
val hasDnsPathMismatch = comparison?.dnsPathMismatch == true
if (hasDnsPathMismatch) {
val transportOnly = comparison.usedCurlCompatibleFallback() &&
comparison.curlCompatible.transportDiagnostics.resolveStrategy != TunProbeResolveStrategy.KOTLIN_INJECTED
val confidence = if (transportOnly) EvidenceConfidence.MEDIUM else EvidenceConfidence.HIGH
evidence.add(
EvidenceItem(
@ -738,32 +737,19 @@ object DirectSignsChecker {
return SignalOutcome(detected = detected, needsReview = needsReview)
}
private fun addDebugTunProbeFinding(
context: Context,
result: UnderlyingNetworkProber.ProbeResult,
findings: MutableList<Finding>,
) {
val diagnostics = result.tunProbeDiagnostics ?: return
val vpnPath = diagnostics.vpnPath ?: return
findings.add(
Finding(
description = TunProbeDiagnosticsFormatter.formatUiSummary(
context = context,
pathLabel = context.getString(R.string.checker_tun_probe_path_vpn),
modeOverride = diagnostics.modeOverride,
path = vpnPath,
),
isInformational = true,
source = EvidenceSource.TUN_ACTIVE_PROBE,
),
)
}
internal fun isKnownProxyPort(port: String?): Boolean {
val value = port?.toIntOrNull() ?: return false
return value in KNOWN_PROXY_PORTS || KNOWN_PROXY_PORT_RANGES.any { value in it }
}
private fun targetGroupLabel(
context: Context,
targetGroup: com.notcvnt.rknhardering.model.TargetGroup,
): String = when (targetGroup) {
com.notcvnt.rknhardering.model.TargetGroup.RU -> context.getString(R.string.ip_channels_target_ru)
com.notcvnt.rknhardering.model.TargetGroup.NON_RU -> context.getString(R.string.ip_channels_target_non_ru)
}
private fun Throwable.renderMessage(): String {
return message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName
}

View file

@ -20,6 +20,7 @@ object IfconfigClient {
IpEndpointSpec("https://ifconfig.me/ip", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://checkip.amazonaws.com", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://ip.mail.ru", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://api-ipv4.ip.sb/ip", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://api4.ipify.org", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://api6.ipify.org", IpEndpointFamilyHint.IPV6),
)
@ -87,13 +88,13 @@ object IfconfigClient {
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
modeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
collectTrace: Boolean = false,
targetHost: String? = null,
targetUrls: List<String>? = null,
okHttpRetryCount: Int = ResolverNetworkStack.OKHTTP_RETRY_COUNT,
nativeCurlRetryCount: Int = ResolverNetworkStack.NATIVE_CURL_RETRY_COUNT,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): PublicIpNetworkComparison = withContext(Dispatchers.IO) {
val endpoints = if (targetHost != null) {
listOf(IpEndpointSpec("https://$targetHost", IpEndpointFamilyHint.IPV4))
val endpoints = if (!targetUrls.isNullOrEmpty()) {
targetUrls.map(::IpEndpointSpec)
} else {
ENDPOINTS
}

View file

@ -19,6 +19,11 @@ data class PerTargetProbe(
val error: String? = null,
)
private data class ProbeTarget(
val displayHost: String,
val urls: List<String>,
)
/**
* Detects whether a non-VPN (underlying) network is reachable from this app.
*
@ -51,7 +56,7 @@ object UnderlyingNetworkProber {
DnsResolverConfig,
Boolean,
TunProbeModeOverride,
String?,
List<String>?,
) -> PublicIpNetworkComparison = ::fetchIpViaNetworkComparison,
)
@ -77,16 +82,39 @@ object UnderlyingNetworkProber {
val activeNetworkIsVpn: Boolean? = null,
val tunProbeDiagnostics: TunProbeDiagnostics? = null,
) {
val vpnIp: String? get() = ruTarget.vpnIp ?: nonRuTarget.vpnIp
val underlyingIp: String? get() = ruTarget.directIp ?: nonRuTarget.directIp
private fun preferredComparisonTarget(): PerTargetProbe? {
val candidates = listOf(nonRuTarget, ruTarget).filter {
it.vpnIp != null || it.directIp != null
}
return candidates.firstOrNull { it.vpnIp != null && it.directIp != null && it.vpnIp != it.directIp }
?: candidates.firstOrNull { it.targetGroup == com.notcvnt.rknhardering.model.TargetGroup.NON_RU }
?: candidates.firstOrNull()
}
val vpnIp: String? get() = preferredComparisonTarget()?.vpnIp
val underlyingIp: String? get() = preferredComparisonTarget()?.directIp
val vpnIpComparison: PublicIpNetworkComparison?
get() = ruTarget.comparison ?: nonRuTarget.comparison
get() = preferredComparisonTarget()?.comparison ?: ruTarget.comparison ?: nonRuTarget.comparison
val underlyingIpComparison: PublicIpNetworkComparison?
get() = ruTarget.comparison ?: nonRuTarget.comparison
get() = preferredComparisonTarget()?.comparison ?: ruTarget.comparison ?: nonRuTarget.comparison
}
private const val RU_PROBE_HOST = "ifconfig.yandex.ru"
private const val NON_RU_PROBE_HOST = "api.ipify.org"
private val RU_PROBE_TARGET = ProbeTarget(
displayHost = "ipv4-internet.yandex.net",
urls = listOf(
"https://ipv4-internet.yandex.net/api/v0/ip",
"https://ip.mail.ru",
),
)
private val NON_RU_PROBE_TARGET = ProbeTarget(
displayHost = "api-ipv4.ip.sb",
urls = listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
),
)
@Suppress("DEPRECATION")
suspend fun probe(
@ -130,26 +158,26 @@ object UnderlyingNetworkProber {
resolverConfig,
debugEnabled,
modeOverride,
RU_PROBE_HOST,
RU_PROBE_TARGET.urls,
)
val nonRuVpnComparison = dependencies.comparisonFetcher(
vpnNetwork,
resolverConfig,
debugEnabled,
modeOverride,
NON_RU_PROBE_HOST,
NON_RU_PROBE_TARGET.urls,
)
val vpnError = ruVpnComparison.selectedError ?: nonRuVpnComparison.selectedError
val ruVpnProbe = PerTargetProbe(
targetHost = RU_PROBE_HOST,
targetHost = RU_PROBE_TARGET.displayHost,
targetGroup = com.notcvnt.rknhardering.model.TargetGroup.RU,
vpnIp = ruVpnComparison.selectedIp,
comparison = ruVpnComparison,
error = ruVpnComparison.selectedError,
)
val nonRuVpnProbe = PerTargetProbe(
targetHost = NON_RU_PROBE_HOST,
targetHost = NON_RU_PROBE_TARGET.displayHost,
targetGroup = com.notcvnt.rknhardering.model.TargetGroup.NON_RU,
vpnIp = nonRuVpnComparison.selectedIp,
comparison = nonRuVpnComparison,
@ -199,7 +227,7 @@ object UnderlyingNetworkProber {
resolverConfig,
debugEnabled,
modeOverride,
RU_PROBE_HOST,
RU_PROBE_TARGET.urls,
)
ruUnderlyingComparison = ruResult
ruUnderlyingIp = ruResult.selectedIp
@ -211,7 +239,7 @@ object UnderlyingNetworkProber {
resolverConfig,
debugEnabled,
modeOverride,
NON_RU_PROBE_HOST,
NON_RU_PROBE_TARGET.urls,
)
nonRuUnderlyingComparison = nonRuResult
nonRuUnderlyingIp = nonRuResult.selectedIp
@ -228,12 +256,12 @@ object UnderlyingNetworkProber {
val ruTarget = ruVpnProbe.copy(
directIp = ruUnderlyingIp,
comparison = ruUnderlyingComparison ?: ruVpnComparison,
error = if (ruUnderlyingIp == null && ruUnderlyingError != null) ruUnderlyingError else ruVpnComparison.selectedError,
error = ruVpnComparison.selectedError,
)
val nonRuTarget = nonRuVpnProbe.copy(
directIp = nonRuUnderlyingIp,
comparison = nonRuUnderlyingComparison ?: nonRuVpnComparison,
error = if (nonRuUnderlyingIp == null && nonRuUnderlyingError != null) nonRuUnderlyingError else nonRuVpnComparison.selectedError,
error = nonRuVpnComparison.selectedError,
)
ProbeResult(
@ -288,7 +316,7 @@ object UnderlyingNetworkProber {
resolverConfig: DnsResolverConfig,
debugEnabled: Boolean,
modeOverride: TunProbeModeOverride,
targetHost: String? = null,
targetUrls: List<String>? = null,
): PublicIpNetworkComparison {
val fallbackBinding = networkSnapshot.interfaceName
?.takeIf { it.isNotBlank() }
@ -300,7 +328,7 @@ object UnderlyingNetworkProber {
resolverConfig = resolverConfig,
modeOverride = modeOverride,
collectTrace = debugEnabled,
targetHost = targetHost,
targetUrls = targetUrls,
)
}

View file

@ -449,8 +449,12 @@
</plurals>
<string name="checker_bypass_vpn_not_active">Underlying network: VPN فعال نیست، این بررسی لازم نیست</string>
<string name="checker_bypass_vpn_network_binding">VPN network binding: برنامه هم به مسیر پیش‌فرض غیر VPN و هم به VPN Network دسترسی دارد (VPN IP: %1$s, IP پیش‌فرض: %2$s)</string>
<string name="checker_bypass_tun_probe_success">TUN active probe: درخواست از طریق VPN Network، IP %1$s را برگرداند (TUN یک اینترفیس زنده و قابل مسیریابی است)</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN active probe: مسیر transport-only سازگار با curl، IP %1$s را برگرداند (SO_BINDTODEVICE + DNS سیستم)</string>
<string name="checker_bypass_tun_probe_success">کاوش فعال TUN: آی‌پی %1$s</string>
<string name="checker_bypass_tun_probe_success_direct">کاوش فعال TUN، مستقیم: آی‌پی %1$s</string>
<string name="checker_bypass_tun_probe_success_proxy">کاوش فعال TUN، پراکسی: آی‌پی %1$s</string>
<string name="checker_bypass_tun_probe_success_direct_target">کاوش فعال TUN، %1$s، مستقیم: آی‌پی %2$s</string>
<string name="checker_bypass_tun_probe_success_proxy_target">کاوش فعال TUN، %1$s، از طریق پراکسی: آی‌پی %2$s</string>
<string name="checker_bypass_tun_probe_success_transport_only">کاوش فعال TUN: آی‌پی %1$s</string>
<string name="checker_bypass_tun_probe_failure">TUN active probe: درخواست از طریق VPN Network در دسترس نیست (اینترفیس مسدود یا filtered است)</string>
<string name="checker_bypass_tun_probe_failure_reason">بررسی فعال TUN: درخواست از طریق VPN Network با خطا شکست خورد (%1$s)</string>
<string name="checker_bypass_tun_probe_dns_mismatch">بررسی فعال TUN: strict same-path شکست خورد (%1$s)، اما مسیر transport-only سازگار با curl موفق شد؛ احتمال mismatch در مسیر DNS وجود دارد</string>
@ -471,7 +475,7 @@
<string name="checker_tun_probe_status_succeeded">succeeded</string>
<string name="checker_tun_probe_status_failed">failed</string>
<string name="checker_tun_probe_status_skipped">skipped</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">کاوش فعال TUN: مسیر curl-compatible آی‌پی %1$s را برگرداند</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">کاوش فعال TUN: آی‌پی %1$s</string>
<string name="checker_bypass_tun_probe_dns_mismatch_curl_compatible">کاوش فعال TUN: strict same-path شکست خورد (%1$s)، اما مسیر curl-compatible موفق شد؛ احتمال mismatch در DNS-path زیاد است</string>
<string name="checker_bypass_curl_compatible_used">مقایسه split tunnel از fallback نوع curl-compatible برای این مسیرها استفاده کرد: %1$s</string>

View file

@ -454,9 +454,13 @@
</plurals>
<string name="checker_bypass_vpn_not_active">Underlying network: VPN не активен, проверка не требуется</string>
<string name="checker_bypass_vpn_network_binding">VPN network binding: приложение видит и VPN Network, и non-VPN default сеть (VPN IP: %1$s, direct IP: %2$s)</string>
<string name="checker_bypass_tun_probe_success">TUN активный зонд: запрос через VPN Network вернул IP %1$s (TUN — живой маршрутизируемый интерфейс)</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN активный зонд: curl-compatible transport-only путь вернул IP %1$s (SO_BINDTODEVICE + системный DNS)</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN активный зонд: curl-compatible путь вернул IP %1$s</string>
<string name="checker_bypass_tun_probe_success">TUN активный зонд: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_direct">TUN активный зонд, напрямую: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_proxy">TUN активный зонд, прокси: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_direct_target">TUN активный зонд, %1$s, напрямую: IP %2$s</string>
<string name="checker_bypass_tun_probe_success_proxy_target">TUN активный зонд, %1$s, через прокси: IP %2$s</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN активный зонд: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN активный зонд: IP %1$s</string>
<string name="checker_bypass_tun_probe_failure">TUN активный зонд: запрос через VPN Network недоступен (интерфейс заблокирован или filtered)</string>
<string name="checker_bypass_tun_probe_failure_reason">TUN активный зонд: запрос через VPN Network завершился ошибкой (%1$s)</string>
<string name="checker_bypass_tun_probe_dns_mismatch">TUN активный зонд: strict same-path завершился ошибкой (%1$s), но curl-compatible transport-only путь сработал; вероятен mismatch DNS-path</string>

View file

@ -447,9 +447,13 @@
</plurals>
<string name="checker_bypass_vpn_not_active">Underlying networkVPN 未激活,无需执行此检查</string>
<string name="checker_bypass_vpn_network_binding">VPN network binding应用同时访问到了默认非 VPN 路径和 VPN NetworkVPN IP%1$s默认 IP%2$s</string>
<string name="checker_bypass_tun_probe_success">TUN active probe通过 VPN Network 发起的请求返回了 IP %1$sTUN 是一个可路由的活动接口)</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN active probecurl-compatible transport-only 路径返回了 IP %1$sSO_BINDTODEVICE + system DNS</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN 活跃探测curl-compatible 路径返回了 IP %1$s</string>
<string name="checker_bypass_tun_probe_success">TUN 活跃探测IP %1$s</string>
<string name="checker_bypass_tun_probe_success_direct">TUN 活跃探测直连IP %1$s</string>
<string name="checker_bypass_tun_probe_success_proxy">TUN 活跃探测代理IP %1$s</string>
<string name="checker_bypass_tun_probe_success_direct_target">TUN 活跃探测,%1$s直连IP %2$s</string>
<string name="checker_bypass_tun_probe_success_proxy_target">TUN 活跃探测,%1$s经代理IP %2$s</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN 活跃探测IP %1$s</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN 活跃探测IP %1$s</string>
<string name="checker_bypass_tun_probe_failure">TUN active probe通过 VPN Network 发起的请求不可用(接口被阻止或被 filtered</string>
<string name="checker_bypass_tun_probe_failure_reason">TUN 活跃探测:通过 VPN Network 发起的请求失败(%1$s</string>
<string name="checker_bypass_tun_probe_dns_mismatch">TUN 活跃探测strict same-path 失败(%1$s但 curl-compatible transport-only 路径成功;很可能存在 DNS path mismatch</string>

View file

@ -450,9 +450,13 @@
</plurals>
<string name="checker_bypass_vpn_not_active">Underlying network: VPN is not active, the check is not required</string>
<string name="checker_bypass_vpn_network_binding">VPN network binding: the app can reach both the default non-VPN path and VPN Network (VPN IP: %1$s, default IP: %2$s)</string>
<string name="checker_bypass_tun_probe_success">TUN active probe: a request via VPN Network returned IP %1$s (TUN is a live routable interface)</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN active probe: a curl-compatible transport-only path returned IP %1$s (SO_BINDTODEVICE + system DNS)</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN active probe: a curl-compatible path returned IP %1$s</string>
<string name="checker_bypass_tun_probe_success">TUN active probe: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_direct">TUN active probe, direct: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_proxy">TUN active probe, proxy: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_direct_target">TUN active probe, %1$s, direct: IP %2$s</string>
<string name="checker_bypass_tun_probe_success_proxy_target">TUN active probe, %1$s, via proxy: IP %2$s</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN active probe: IP %1$s</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN active probe: IP %1$s</string>
<string name="checker_bypass_tun_probe_failure">TUN active probe: a request via VPN Network is unavailable (the interface is blocked or filtered)</string>
<string name="checker_bypass_tun_probe_failure_reason">TUN active probe: a request via VPN Network failed (%1$s)</string>
<string name="checker_bypass_tun_probe_dns_mismatch">TUN active probe: strict same-path failed (%1$s), but the curl-compatible transport-only path succeeded; DNS path mismatch is likely</string>

View file

@ -238,6 +238,39 @@ class BypassCheckerTest {
assertTrue(findings.any { it.needsReview && it.description.contains("different IP families") })
}
@Test
fun `gateway leak prefers non ru target when ru target masks the mismatch`() {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
val outcome = BypassChecker.reportUnderlyingNetworkResult(
context = context,
result = UnderlyingNetworkProber.ProbeResult(
vpnActive = true,
underlyingReachable = true,
ruTarget = PerTargetProbe(
targetHost = "ipv4-internet.yandex.net",
targetGroup = TargetGroup.RU,
vpnIp = "37.112.0.10",
directIp = "37.112.0.10",
),
nonRuTarget = PerTargetProbe(
targetHost = "api-ipv4.ip.sb",
targetGroup = TargetGroup.NON_RU,
vpnIp = "37.112.0.10",
directIp = "157.180.0.10",
),
activeNetworkIsVpn = true,
),
findings = findings,
evidence = evidence,
)
assertTrue(outcome.detected)
assertFalse(outcome.needsReview)
assertTrue(evidence.any { it.source == EvidenceSource.VPN_GATEWAY_LEAK && it.detected })
}
@Test
fun `gateway leak falls back to needs review when vpn comparison relies on curl compatible fallback`() {
val findings = mutableListOf<Finding>()
@ -315,8 +348,8 @@ class BypassCheckerTest {
assertFalse(evidence.any { it.source == EvidenceSource.VPN_GATEWAY_LEAK && it.detected })
assertTrue(findings.any { it.needsReview && it.source == EvidenceSource.VPN_GATEWAY_LEAK })
assertTrue(findings.any { it.isInformational && it.description.contains("curl-compatible transport-only fallback") })
assertTrue(findings.any { it.isInformational && it.description.contains("VPN path debug") })
assertTrue(findings.any { it.isInformational && it.description.contains("underlying path debug") })
assertFalse(findings.any { it.isInformational && it.description.contains("VPN path debug") })
assertFalse(findings.any { it.isInformational && it.description.contains("underlying path debug") })
}
@Test

View file

@ -385,21 +385,14 @@ class DirectSignsCheckerTest {
result.findings.any {
it.isInformational &&
it.source == EvidenceSource.TUN_ACTIVE_PROBE &&
it.description.contains("SO_BINDTODEVICE + system DNS")
},
)
assertTrue(
result.findings.any {
it.isInformational &&
it.description.contains("effective mode Curl-compatible") &&
it.description.contains("selected mode Curl-compatible") &&
it.description.contains("dnsPathMismatch true")
(it.description.contains("proxy") || it.description.contains("прокси")) &&
(it.description.contains("RU") || it.description.contains("Не-RU"))
},
)
assertFalse(
result.findings.any {
it.isInformational &&
it.description.contains("effective mode Auto")
it.description.contains("VPN path debug")
},
)
}

View file

@ -360,6 +360,82 @@ class IfconfigClientTest {
assertFalse(comparison.dnsPathMismatch)
}
@Test
fun `network comparison uses exact target url for strict and curl compatible probes`() {
val requestedEndpoints = mutableListOf<String>()
PublicIpClient.fetchIpOverride = { endpoint, _, _, _, binding ->
requestedEndpoints += "strict:$endpoint:${binding?.javaClass?.simpleName}"
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("strict failed"))
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
NativeCurlBridge.executeOverride = { request ->
requestedEndpoints += "curl:${request.url}"
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.57",
)
}
val comparison = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetworkComparison(
primaryBinding = ResolverBinding.AndroidNetworkBinding(newNetwork(212)),
fallbackBinding = ResolverBinding.OsDeviceBinding(
interfaceName = "tun0",
dnsMode = ResolverBinding.DnsMode.SYSTEM,
),
resolverConfig = DnsResolverConfig.system(),
targetUrls = listOf("https://ipv4-internet.yandex.net/api/v0/ip"),
)
}
assertEquals(PublicIpProbeMode.CURL_COMPATIBLE, comparison.selectedMode)
assertTrue(
requestedEndpoints.contains(
"strict:https://ipv4-internet.yandex.net/api/v0/ip:AndroidNetworkBinding",
),
)
assertTrue(requestedEndpoints.contains("curl:https://ipv4-internet.yandex.net/api/v0/ip"))
}
@Test
fun `network comparison falls back to next custom target url`() {
val strictEndpoints = mutableListOf<String>()
PublicIpClient.fetchIpOverride = { endpoint, _, _, _, binding ->
strictEndpoints += endpoint
when {
binding is ResolverBinding.AndroidNetworkBinding && endpoint.contains("checkip.amazonaws.com") ->
Result.failure(IOException("connection closed"))
binding is ResolverBinding.AndroidNetworkBinding && endpoint.contains("api-ipv4.ip.sb") ->
Result.success("203.0.113.58")
binding == null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
val comparison = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetworkComparison(
primaryBinding = ResolverBinding.AndroidNetworkBinding(newNetwork(213)),
fallbackBinding = null,
resolverConfig = DnsResolverConfig.system(),
targetUrls = listOf(
"https://checkip.amazonaws.com",
"https://api-ipv4.ip.sb/ip",
),
)
}
assertEquals(PublicIpProbeMode.STRICT_SAME_PATH, comparison.selectedMode)
assertEquals("203.0.113.58", comparison.selectedIp)
assertEquals(
listOf("https://checkip.amazonaws.com", "https://api-ipv4.ip.sb/ip"),
strictEndpoints,
)
}
@Test
fun `curl compatible uses injected resolve for direct resolver`() {
var observedBinding: ResolverBinding? = null

View file

@ -107,12 +107,22 @@ class UnderlyingNetworkProberTest {
),
comparisons = mapOf(
vpnNetwork to mapOf(
"ifconfig.yandex.ru" to successfulComparison("198.51.100.10"),
"api.ipify.org" to successfulComparison("203.0.113.10"),
listOf("https://ipv4-internet.yandex.net/api/v0/ip", "https://ip.mail.ru") to successfulComparison("198.51.100.10"),
listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
) to successfulComparison("203.0.113.10"),
),
wifiNetwork to mapOf(
"ifconfig.yandex.ru" to successfulComparison("203.0.113.1"),
"api.ipify.org" to successfulComparison("203.0.113.2"),
listOf("https://ipv4-internet.yandex.net/api/v0/ip", "https://ip.mail.ru") to successfulComparison("203.0.113.1"),
listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
) to successfulComparison("203.0.113.2"),
),
),
)
@ -128,8 +138,10 @@ class UnderlyingNetworkProberTest {
assertEquals("203.0.113.10", result.nonRuTarget.vpnIp)
assertEquals("203.0.113.1", result.ruTarget.directIp)
assertEquals("203.0.113.2", result.nonRuTarget.directIp)
assertEquals("ifconfig.yandex.ru", result.ruTarget.targetHost)
assertEquals("api.ipify.org", result.nonRuTarget.targetHost)
assertEquals("ipv4-internet.yandex.net", result.ruTarget.targetHost)
assertEquals("api-ipv4.ip.sb", result.nonRuTarget.targetHost)
assertEquals("203.0.113.10", result.vpnIp)
assertEquals("203.0.113.2", result.underlyingIp)
}
@Test
@ -140,8 +152,13 @@ class UnderlyingNetworkProberTest {
snapshots = listOf(snapshot(vpnNetwork, "tun0", hasVpnTransport = true)),
comparisons = mapOf(
vpnNetwork to mapOf(
"ifconfig.yandex.ru" to failureComparison("RU endpoint timeout"),
"api.ipify.org" to successfulComparison("203.0.113.10"),
listOf("https://ipv4-internet.yandex.net/api/v0/ip", "https://ip.mail.ru") to failureComparison("RU endpoint timeout"),
listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
) to successfulComparison("203.0.113.10"),
),
),
)
@ -166,8 +183,13 @@ class UnderlyingNetworkProberTest {
snapshots = listOf(snapshot(vpnNetwork, "tun0", hasVpnTransport = true)),
comparisons = mapOf(
vpnNetwork to mapOf(
"ifconfig.yandex.ru" to successfulComparison("198.51.100.10"),
"api.ipify.org" to successfulComparison("203.0.113.10"),
listOf("https://ipv4-internet.yandex.net/api/v0/ip", "https://ip.mail.ru") to successfulComparison("198.51.100.10"),
listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
) to successfulComparison("203.0.113.10"),
),
),
)
@ -185,7 +207,7 @@ class UnderlyingNetworkProberTest {
private fun installDependencies(
activeNetwork: Network?,
snapshots: List<UnderlyingNetworkProber.NetworkSnapshot>,
comparisons: Map<Network, Map<String, PublicIpNetworkComparison>>,
comparisons: Map<Network, Map<List<String>, PublicIpNetworkComparison>>,
) {
UnderlyingNetworkProber.dependenciesOverride = UnderlyingNetworkProber.Dependencies(
initNativeCurl = {},
@ -195,14 +217,56 @@ class UnderlyingNetworkProberTest {
networks = snapshots,
)
},
comparisonFetcher = { snapshot, _, _, _, targetHost ->
val host = requireNotNull(targetHost)
comparisons[snapshot.network]?.get(host)
?: failureComparison("Missing test comparison for ${snapshot.network} $host")
comparisonFetcher = { snapshot, _, _, _, targetUrls ->
val urls = requireNotNull(targetUrls)
comparisons[snapshot.network]?.get(urls)
?: failureComparison("Missing test comparison for ${snapshot.network} $urls")
},
)
}
@Test
fun `probe keeps vpn error separate from underlying error`() = runBlocking {
val vpnNetwork = newNetwork(305)
val wifiNetwork = newNetwork(306)
installDependencies(
activeNetwork = vpnNetwork,
snapshots = listOf(
snapshot(vpnNetwork, "tun0", hasVpnTransport = true),
snapshot(wifiNetwork, "wlan0", hasVpnTransport = false),
),
comparisons = mapOf(
vpnNetwork to mapOf(
listOf("https://ipv4-internet.yandex.net/api/v0/ip", "https://ip.mail.ru") to successfulComparison("198.51.100.10"),
listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
) to failureComparison("vpn non-ru timeout"),
),
wifiNetwork to mapOf(
listOf("https://ipv4-internet.yandex.net/api/v0/ip", "https://ip.mail.ru") to successfulComparison("203.0.113.1"),
listOf(
"https://api-ipv4.ip.sb/ip",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://api4.ipify.org",
) to failureComparison("underlying non-ru timeout"),
),
),
)
val result = UnderlyingNetworkProber.probe(
context = context,
resolverConfig = DnsResolverConfig.system(),
)
assertEquals(null, result.ruTarget.error)
assertEquals("vpn non-ru timeout", result.nonRuTarget.error)
assertEquals("underlying non-ru timeout", result.underlyingError)
}
private fun snapshot(
network: Network,
interfaceName: String,