fix: починить STUN-проверку и статусы

Closes #14
This commit is contained in:
xtclovver 2026-04-16 03:57:18 +03:00
parent c739864632
commit 2e5f64c7e4
10 changed files with 220 additions and 10 deletions

View file

@ -116,6 +116,50 @@ internal fun maskInfoValue(value: String, privacyMode: Boolean): String {
return if (privacyMode) maskIpsInText(value) else value
}
private const val CALL_TRANSPORT_NO_STUN_RESPONSE_MARKER = "did not receive a STUN response"
private const val CALL_TRANSPORT_TELEGRAM_DC_UNREACHABLE_MARKER = "did not expose a reachable Telegram DC"
internal fun formatCallTransportReason(
context: android.content.Context,
leak: CallTransportLeakResult,
privacyMode: Boolean,
): String? {
val summary = leak.summary.trim()
return when {
leak.status == CallTransportStatus.BASELINE || leak.status == CallTransportStatus.NEEDS_REVIEW -> null
summary.contains(CALL_TRANSPORT_NO_STUN_RESPONSE_MARKER, ignoreCase = true) ->
buildCallTransportReason(
base = context.getString(R.string.main_card_call_transport_reason_no_response),
detail = summaryDetailAfterMarker(summary, CALL_TRANSPORT_NO_STUN_RESPONSE_MARKER),
privacyMode = privacyMode,
)
summary.contains(CALL_TRANSPORT_TELEGRAM_DC_UNREACHABLE_MARKER, ignoreCase = true) ->
buildCallTransportReason(
base = context.getString(R.string.main_card_call_transport_reason_telegram_dc_unreachable),
detail = summaryDetailAfterMarker(summary, CALL_TRANSPORT_TELEGRAM_DC_UNREACHABLE_MARKER),
privacyMode = privacyMode,
)
summary.contains("experimental trace is disabled in release builds", ignoreCase = true) ->
context.getString(R.string.main_card_call_transport_reason_release_only)
summary.contains("targets are unavailable", ignoreCase = true) ||
summary.contains("target catalog is unavailable", ignoreCase = true) ->
context.getString(R.string.main_card_call_transport_reason_targets_unavailable)
else -> maskInfoValue(summary, privacyMode)
}
}
private fun buildCallTransportReason(base: String, detail: String?, privacyMode: Boolean): String {
val maskedDetail = detail
?.takeIf { it.isNotBlank() }
?.let { maskInfoValue(it, privacyMode) }
return if (maskedDetail.isNullOrBlank()) base else "$base: $maskedDetail"
}
private fun summaryDetailAfterMarker(summary: String, marker: String): String? {
val tail = summary.substringAfter(marker, missingDelimiterValue = "").trim()
return tail.removePrefix(":").trim().takeIf { it.isNotBlank() }
}
private fun isUniqueLocalIpv6(address: Inet6Address): Boolean {
val firstByte = address.address.firstOrNull()?.toInt()?.and(0xff) ?: return false
return (firstByte and 0xfe) == 0xfc
@ -1712,6 +1756,7 @@ class MainActivity : AppCompatActivity() {
}
val statusLabel = when (leak.status) {
CallTransportStatus.BASELINE -> getString(R.string.main_card_call_transport_status_baseline)
CallTransportStatus.NO_SIGNAL -> getString(R.string.main_card_call_transport_status_no_signal)
CallTransportStatus.NEEDS_REVIEW -> getString(R.string.main_card_call_transport_status_needs_review)
CallTransportStatus.UNSUPPORTED -> getString(R.string.main_card_call_transport_status_unsupported)
@ -1727,9 +1772,10 @@ class MainActivity : AppCompatActivity() {
CallTransportService.WHATSAPP -> "WhatsApp"
}
val statusColor = when (leak.status) {
CallTransportStatus.BASELINE -> R.color.status_green
CallTransportStatus.NEEDS_REVIEW -> R.color.status_amber
CallTransportStatus.ERROR -> R.color.status_amber
CallTransportStatus.NO_SIGNAL -> R.color.status_green
CallTransportStatus.NO_SIGNAL -> R.color.md_on_surface_variant
CallTransportStatus.UNSUPPORTED -> R.color.md_on_surface_variant
}
@ -1739,9 +1785,10 @@ class MainActivity : AppCompatActivity() {
}
val indicator = TextView(this).apply {
text = when (leak.status) {
CallTransportStatus.BASELINE -> "\u2713"
CallTransportStatus.NEEDS_REVIEW -> "?"
CallTransportStatus.ERROR -> "\u26A0"
CallTransportStatus.NO_SIGNAL -> "\u2713"
CallTransportStatus.NO_SIGNAL -> "\u2014"
CallTransportStatus.UNSUPPORTED -> "\u2014"
}
setTextColor(ContextCompat.getColor(this@MainActivity, statusColor))
@ -1761,6 +1808,19 @@ class MainActivity : AppCompatActivity() {
headerRow.addView(headerText)
container.addView(headerRow)
formatCallTransportReason(this, leak, privacyMode)?.let { reason ->
val reasonColor = when (leak.status) {
CallTransportStatus.ERROR -> R.color.status_amber
else -> R.color.md_on_surface_variant
}
container.addView(TextView(this).apply {
text = reason
textSize = 12f
setPadding(22.dp, 2.dp, 0, 0)
setTextColor(ContextCompat.getColor(this@MainActivity, reasonColor))
})
}
val target = leak.targetHost
if (target != null) {
val port = leak.targetPort

View file

@ -479,7 +479,7 @@ object CallTransportChecker {
) {
CallTransportStatus.NEEDS_REVIEW
} else {
CallTransportStatus.NO_SIGNAL
CallTransportStatus.BASELINE
}
}
}
@ -680,8 +680,9 @@ object CallTransportChecker {
return when (status) {
CallTransportStatus.NEEDS_REVIEW -> 4
CallTransportStatus.ERROR -> 3
CallTransportStatus.NO_SIGNAL -> 2
CallTransportStatus.UNSUPPORTED -> 1
CallTransportStatus.BASELINE -> 2
CallTransportStatus.NO_SIGNAL -> 1
CallTransportStatus.UNSUPPORTED -> 0
}
}
@ -717,6 +718,7 @@ object CallTransportChecker {
}
CallTransportStatus.NO_SIGNAL,
CallTransportStatus.UNSUPPORTED,
CallTransportStatus.BASELINE,
-> Unit
}
}

View file

@ -46,6 +46,7 @@ enum class CallTransportProbeKind {
}
enum class CallTransportStatus {
BASELINE,
NO_SIGNAL,
NEEDS_REVIEW,
UNSUPPORTED,

View file

@ -44,6 +44,7 @@ object ResolverNetworkStack {
config: DnsResolverConfig,
binding: ResolverBinding? = null,
): List<InetAddress> {
literalToInetAddress(hostname)?.let { return listOf(it) }
return dns(config, binding).lookup(hostname)
}

View file

@ -83,13 +83,18 @@
<string name="main_card_direct_signs">Прямые признаки</string>
<string name="main_card_indirect_signs">Косвенные признаки</string>
<string name="main_card_call_transport">STUN-серверы</string>
<string name="main_card_call_transport_status_no_signal">Нет сигнала</string>
<string name="main_card_call_transport_status_baseline">Ответ есть</string>
<string name="main_card_call_transport_status_no_signal">Нет ответа</string>
<string name="main_card_call_transport_status_needs_review">Требует проверки</string>
<string name="main_card_call_transport_status_unsupported">Не поддерживается</string>
<string name="main_card_call_transport_status_error">Ошибка</string>
<string name="main_card_call_transport_path_active">Активная сеть</string>
<string name="main_card_call_transport_path_underlying">Базовая сеть</string>
<string name="main_card_call_transport_path_proxy">Локальный прокси</string>
<string name="main_card_call_transport_reason_no_response">STUN-ответ не получен</string>
<string name="main_card_call_transport_reason_telegram_dc_unreachable">Telegram DC недостижим через локальный SOCKS5-прокси</string>
<string name="main_card_call_transport_reason_release_only">Трассировка WhatsApp доступна только в debug или experimental сборке</string>
<string name="main_card_call_transport_reason_targets_unavailable">Цели для этой проверки недоступны</string>
<string name="main_card_location_signals">Сигналы местоположения</string>
<string name="main_verdict_details">Подробнее</string>

View file

@ -84,13 +84,18 @@
<string name="main_card_direct_signs">Direct signs</string>
<string name="main_card_indirect_signs">Indirect signs</string>
<string name="main_card_call_transport">STUN servers</string>
<string name="main_card_call_transport_status_no_signal">No signal</string>
<string name="main_card_call_transport_status_baseline">Responded</string>
<string name="main_card_call_transport_status_no_signal">No response</string>
<string name="main_card_call_transport_status_needs_review">Needs review</string>
<string name="main_card_call_transport_status_unsupported">Unsupported</string>
<string name="main_card_call_transport_status_error">Error</string>
<string name="main_card_call_transport_path_active">Active network</string>
<string name="main_card_call_transport_path_underlying">Underlying network</string>
<string name="main_card_call_transport_path_proxy">Local proxy</string>
<string name="main_card_call_transport_reason_no_response">No STUN response</string>
<string name="main_card_call_transport_reason_telegram_dc_unreachable">Telegram DC is unreachable through the local SOCKS5 proxy</string>
<string name="main_card_call_transport_reason_release_only">WhatsApp tracing is available only in debug or experimental builds</string>
<string name="main_card_call_transport_reason_targets_unavailable">Targets for this check are unavailable</string>
<string name="main_card_location_signals">Location signals</string>
<string name="main_verdict_details">Details</string>

View file

@ -0,0 +1,97 @@
package com.notcvnt.rknhardering
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.model.CallTransportLeakResult
import com.notcvnt.rknhardering.model.CallTransportNetworkPath
import com.notcvnt.rknhardering.model.CallTransportProbeKind
import com.notcvnt.rknhardering.model.CallTransportService
import com.notcvnt.rknhardering.model.CallTransportStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class MainActivityCallTransportReasonTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `formatCallTransportReason localizes stun timeout`() {
val leak = leak(
status = CallTransportStatus.NO_SIGNAL,
summary = "Telegram call transport via active network did not receive a STUN response: timeout",
)
val reason = formatCallTransportReason(context, leak, privacyMode = false)
assertEquals(
"${context.getString(R.string.main_card_call_transport_reason_no_response)}: timeout",
reason,
)
}
@Test
fun `formatCallTransportReason localizes release only whatsapp`() {
val leak = leak(
service = CallTransportService.WHATSAPP,
status = CallTransportStatus.UNSUPPORTED,
summary = "WhatsApp experimental trace is disabled in release builds",
)
val reason = formatCallTransportReason(context, leak, privacyMode = false)
assertEquals(context.getString(R.string.main_card_call_transport_reason_release_only), reason)
}
@Test
fun `formatCallTransportReason masks ip details in privacy mode`() {
val leak = leak(
status = CallTransportStatus.NO_SIGNAL,
summary = "Telegram call transport via active network did not receive a STUN response: timeout from 203.0.113.10",
)
val reason = formatCallTransportReason(context, leak, privacyMode = true)
assertEquals(
"${context.getString(R.string.main_card_call_transport_reason_no_response)}: timeout from 203.0.*.*",
reason,
)
}
@Test
fun `formatCallTransportReason keeps needs review without extra text`() {
val leak = leak(
status = CallTransportStatus.BASELINE,
summary = "Telegram call transport via active network: STUN endpoint responded",
)
assertNull(formatCallTransportReason(context, leak, privacyMode = false))
}
@Test
fun `formatCallTransportReason keeps review status without extra text`() {
val leak = leak(
status = CallTransportStatus.NEEDS_REVIEW,
summary = "Telegram call transport via active network: STUN endpoint responded",
)
assertNull(formatCallTransportReason(context, leak, privacyMode = false))
}
private fun leak(
service: CallTransportService = CallTransportService.TELEGRAM,
status: CallTransportStatus,
summary: String,
): CallTransportLeakResult {
return CallTransportLeakResult(
service = service,
probeKind = CallTransportProbeKind.DIRECT_UDP_STUN,
networkPath = CallTransportNetworkPath.ACTIVE,
status = status,
summary = summary,
)
}
}

View file

@ -40,7 +40,7 @@ class CallTransportCheckerTest {
}
@Test
fun `probeDirect keeps active path as baseline signal`() {
fun `probeDirect marks active path baseline when stun responds without leak`() {
CallTransportChecker.dependenciesOverride = CallTransportChecker.Dependencies(
loadCatalog = { _, _ -> catalogWithTelegramTarget() },
loadPaths = {
@ -57,7 +57,7 @@ class CallTransportCheckerTest {
val results = runBlockingProbeDirect(experimental = false)
val telegram = results.first { it.service == CallTransportService.TELEGRAM }
assertEquals(CallTransportStatus.NO_SIGNAL, telegram.status)
assertEquals(CallTransportStatus.BASELINE, telegram.status)
assertEquals(CallTransportProbeKind.DIRECT_UDP_STUN, telegram.probeKind)
assertEquals(CallTransportNetworkPath.ACTIVE, telegram.networkPath)
assertEquals("149.154.167.51", telegram.targetHost)

View file

@ -92,7 +92,7 @@ class VpnCheckRunnerTest {
)
assertTrue(result.indirectSigns.callTransportLeaks.any { it.status == CallTransportStatus.NEEDS_REVIEW })
assertEquals(Verdict.NEEDS_REVIEW, result.verdict)
assertEquals(Verdict.NOT_DETECTED, result.verdict)
}
@Test

View file

@ -0,0 +1,39 @@
package com.notcvnt.rknhardering.network
import okhttp3.Dns
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Test
import java.net.UnknownHostException
class ResolverNetworkStackTest {
@After
fun tearDown() {
ResolverNetworkStack.dnsFactoryOverride = null
ResolverNetworkStack.resetForTests()
}
@Test
fun `lookup bypasses configured dns for ip literals`() {
var overrideCalls = 0
ResolverNetworkStack.dnsFactoryOverride = { _, _ ->
overrideCalls += 1
object : Dns {
override fun lookup(hostname: String) = throw UnknownHostException("NXDOMAIN")
}
}
val resolved = ResolverNetworkStack.lookup(
hostname = "149.154.167.51",
config = DnsResolverConfig(
mode = DnsResolverMode.DOH,
preset = DnsResolverPreset.CUSTOM,
customDohUrl = "https://dns.google/dns-query",
),
)
assertEquals(listOf("149.154.167.51"), resolved.mapNotNull { it.hostAddress })
assertEquals(0, overrideCalls)
}
}