feat: детекция белых списков оператора и per-app TUN-зонд (#61)
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
xtclovver 2026-05-15 03:11:02 +03:00
parent 7b101d20e8
commit 63e9b97a36
35 changed files with 2767 additions and 1140 deletions

17
.gitattributes vendored Normal file
View file

@ -0,0 +1,17 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.jar binary
*.apk binary
*.aab binary
*.so binary
*.keystore binary

View file

@ -29,8 +29,8 @@ android {
applicationId = "com.notcvnt.rknhardering"
minSdk = 26
targetSdk = 36
versionCode = 20701
versionName = "2.7.1"
versionCode = 20702
versionName = "2.7.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN")

View file

@ -21,6 +21,7 @@ import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.StunProbeResult
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.PublicIpTransportDiagnostics
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.TunEndpointAttempt
@ -76,6 +77,7 @@ internal object CheckResultJsonExportFormatter {
)
put("reasons", jsonArray(narrative.reasonRows))
narrative.homeRoutedRoamingNote?.let { put("homeRoutedRoamingNote", it) }
narrative.whitelistNote?.let { put("whitelistNote", it) }
val locationFacts = snapshot.result.locationSignals.locationFacts
if (locationFacts != null) {
put("homeRoutedRoaming", locationFacts.homeRoutedRoaming)
@ -125,6 +127,9 @@ internal object CheckResultJsonExportFormatter {
snapshot.result.tunProbeDiagnostics?.let {
root.put("tunProbeDiagnostics", tunProbeDiagnosticsToJson(it, snapshot.privacyMode))
}
snapshot.result.operatorWhitelistProbe?.let {
root.put("operator_whitelist", operatorWhitelistProbeToJson(it))
}
return root.toString(2)
}
@ -605,6 +610,23 @@ internal object CheckResultJsonExportFormatter {
}
}
private fun operatorWhitelistProbeToJson(probe: OperatorWhitelistProbeResult): JSONObject {
return JSONObject().apply {
put("detected", probe.whitelistDetected)
put("google_reachable", probe.googleReachable)
put("apple_reachable", probe.appleReachable)
put("firefox_reachable", probe.firefoxReachable)
put("russian_control_reachable", probe.russianControlReachable)
put("duration_ms", probe.durationMs)
put(
"errors",
JSONObject().apply {
probe.errors.forEach { (key, value) -> put(key, value) }
},
)
}
}
private fun jsonArray(items: List<String>): JSONArray {
return JSONArray().apply {
items.forEach(::put)

View file

@ -20,6 +20,7 @@ import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.StunProbeResult
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.PublicIpTransportDiagnostics
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
@ -61,6 +62,7 @@ internal object CheckResultMarkdownExportFormatter {
appendCategorySection(builder, context.getString(R.string.main_card_location_signals), result.locationSignals, snapshot.privacyMode)
appendIpChannelsSection(builder, result.ipConsensus, snapshot.privacyMode)
appendTunProbeDiagnosticsSection(builder, result.tunProbeDiagnostics, snapshot.privacyMode)
appendOperatorWhitelistSection(builder, result.operatorWhitelistProbe)
appendBypassSection(builder, context, result.bypassResult, snapshot.privacyMode)
builder.appendLine("## Footer")
builder.appendLine("- Timestamp: ${formatExportTimestamp(snapshot.finishedAtMillis)}")
@ -676,6 +678,21 @@ internal object CheckResultMarkdownExportFormatter {
needsReview
}
private fun appendOperatorWhitelistSection(
builder: StringBuilder,
probe: OperatorWhitelistProbeResult?,
) {
probe ?: return
builder.appendLine("## Белые списки оператора")
builder.appendLine("- Детектировано: ${if (probe.whitelistDetected) "да" else "нет"}")
builder.appendLine("- google.com/generate_204: ${if (probe.googleReachable) "доступен" else "недоступен"}")
builder.appendLine("- apple captive portal: ${if (probe.appleReachable) "доступен" else "недоступен"}")
builder.appendLine("- firefox detectportal: ${if (probe.firefoxReachable) "доступен" else "недоступен"}")
builder.appendLine("- yandex.ru (контроль): ${if (probe.russianControlReachable) "доступен" else "недоступен"}")
builder.appendLine("- Длительность: ${probe.durationMs} мс")
builder.appendLine()
}
private fun appendTunProbeDiagnosticsSection(
builder: StringBuilder,
diagnostics: TunProbeDiagnostics?,

View file

@ -24,6 +24,7 @@ import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.NativeInterfaceProbe
import com.notcvnt.rknhardering.probe.NativeSignsBridge
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
import java.net.NetworkInterface
@ -70,6 +71,8 @@ object DebugDiagnosticsFormatter {
appendCategory(builder, "nativeSigns", result.nativeSigns)
appendNativeSignsRaw(builder, privacyMode)
appendOperatorWhitelist(builder, result.operatorWhitelistProbe)
builder.appendLine()
builder.appendLine("[tunProbe]")
val tunDiagnostics = result.tunProbeDiagnostics
@ -83,6 +86,33 @@ object DebugDiagnosticsFormatter {
return builder.toString().trimEnd()
}
private fun appendOperatorWhitelist(
builder: StringBuilder,
probe: OperatorWhitelistProbeResult?,
) {
builder.appendLine()
builder.appendLine("[operatorWhitelist]")
if (probe == null) {
builder.appendLine("collected: false")
return
}
builder.appendLine("collected: true")
builder.appendLine("detected: ${probe.whitelistDetected}")
builder.appendLine("googleReachable: ${probe.googleReachable}")
builder.appendLine("appleReachable: ${probe.appleReachable}")
builder.appendLine("firefoxReachable: ${probe.firefoxReachable}")
builder.appendLine("russianControlReachable: ${probe.russianControlReachable}")
builder.appendLine("durationMs: ${probe.durationMs}")
if (probe.errors.isEmpty()) {
builder.appendLine("errors: <none>")
} else {
builder.appendLine("errors:")
probe.errors.forEach { (key, value) ->
builder.appendLine("- $key: $value")
}
}
}
private fun appendNativeSignsRaw(builder: StringBuilder, privacyMode: Boolean) {
builder.appendLine()
builder.appendLine("[nativeSigns.raw]")

View file

@ -312,6 +312,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var verdictTitle: TextView
private lateinit var verdictSubtitle: TextView
private lateinit var verdictHomeRoutedRoamingNote: TextView
private lateinit var whitelistWarningBanner: View
private lateinit var hiddenLegacyCardsHost: LinearLayout
private lateinit var btnPrivacyInfo: MaterialButton
private val tiles = mutableMapOf<String, TileHolder>()
@ -546,6 +547,7 @@ class MainActivity : AppCompatActivity() {
verdictTitle = findViewById(R.id.verdictTitle)
verdictSubtitle = findViewById(R.id.verdictSubtitle)
verdictHomeRoutedRoamingNote = findViewById(R.id.verdictHomeRoutedRoamingNote)
whitelistWarningBanner = findViewById(R.id.whitelistWarningBanner)
hiddenLegacyCardsHost = findViewById(R.id.hiddenLegacyCardsHost)
btnPrivacyInfo = findViewById(R.id.btnPrivacyInfo)
btnPrivacyInfo.setOnClickListener { showPrivacyFooterDialog() }
@ -3042,6 +3044,11 @@ class MainActivity : AppCompatActivity() {
}
bindVerdictNarrative(VerdictNarrativeBuilder.build(this, result, privacyMode))
bindWhitelistWarningBanner(result.operatorWhitelistProbe?.whitelistDetected == true)
}
private fun bindWhitelistWarningBanner(show: Boolean) {
whitelistWarningBanner.visibility = if (show) View.VISIBLE else View.GONE
}
private fun bindVerdictNarrative(narrative: VerdictNarrative) {
@ -3161,6 +3168,7 @@ class MainActivity : AppCompatActivity() {
textVerdictExplanation.text = ""
textVerdictExplanation.visibility = View.GONE
bindHomeRoutedRoamingNote(null)
bindWhitelistWarningBanner(false)
verdictDetailsDivider.visibility = View.GONE
btnVerdictDetails.visibility = View.GONE
btnVerdictDetails.text = getString(R.string.main_verdict_details)
@ -3528,6 +3536,7 @@ class MainActivity : AppCompatActivity() {
verdictTitle.text = getString(R.string.verdict_title_idle)
verdictSubtitle.text = getString(R.string.verdict_subtitle_idle)
bindHomeRoutedRoamingNote(null)
bindWhitelistWarningBanner(false)
}
private fun bindVerdictHeroRunning() {
@ -3538,6 +3547,7 @@ class MainActivity : AppCompatActivity() {
verdictTitle.text = getString(R.string.verdict_title_idle)
verdictSubtitle.text = getString(R.string.verdict_subtitle_running)
bindHomeRoutedRoamingNote(null)
bindWhitelistWarningBanner(false)
}
private fun bindVerdictHero(result: CheckResult) {
@ -3552,6 +3562,7 @@ class MainActivity : AppCompatActivity() {
verdictLabel.text = getString(R.string.verdict_label)
verdictTitle.text = getString(titleRes)
verdictSubtitle.text = getString(R.string.verdict_subtitle_done, tiles.size)
bindWhitelistWarningBanner(result.operatorWhitelistProbe?.whitelistDetected == true)
}
private fun applyVerdictHeroColors(visual: StatusVisual) {

View file

@ -41,6 +41,7 @@ data class VerdictNarrative(
val discoveredRows: List<NarrativeRow>,
val reasonRows: List<String>,
val homeRoutedRoamingNote: String? = null,
val whitelistNote: String? = null,
)
object VerdictNarrativeBuilder {
@ -59,9 +60,15 @@ object VerdictNarrativeBuilder {
discoveredRows = buildDiscoveredRows(context, snapshot, exposureStatus, privacyMode),
reasonRows = buildReasonRows(context, result),
homeRoutedRoamingNote = buildHomeRoutedRoamingNote(context, result),
whitelistNote = buildWhitelistNote(context, result),
)
}
private fun buildWhitelistNote(context: Context, result: CheckResult): String? {
if (result.operatorWhitelistProbe?.whitelistDetected != true) return null
return context.getString(R.string.narrative_whitelist_note)
}
private fun buildHomeRoutedRoamingNote(context: Context, result: CheckResult): String? {
val facts = result.locationSignals.locationFacts ?: return null
if (!facts.homeRoutedRoaming) return null

View file

@ -61,6 +61,7 @@ object DirectSignsChecker {
fun check(
context: Context,
tunActiveProbeResult: UnderlyingNetworkProber.ProbeResult? = null,
tunInterfacePresent: Boolean = false,
): CategoryResult {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
@ -76,6 +77,29 @@ object DirectSignsChecker {
detected = detected || systemProxyOutcome.detected
needsReview = needsReview || systemProxyOutcome.needsReview
// Per-app exclusion: tun0 physically present but this app is excluded from VPN,
// so vpnActive=false even though the tunnel is running.
if (tunActiveProbeResult != null && !tunActiveProbeResult.vpnActive && tunInterfacePresent) {
findings.add(
Finding(
description = context.getString(R.string.checker_direct_per_app_vpn_excluded),
detected = true,
source = EvidenceSource.TUN_ACTIVE_PROBE,
confidence = EvidenceConfidence.MEDIUM,
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.TUN_ACTIVE_PROBE,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "App excluded from active per-app VPN (tun0 present but not accessible)",
kind = com.notcvnt.rknhardering.model.VpnAppKind.TARGETED_BYPASS,
),
)
detected = true
}
tunActiveProbeResult
?.takeIf { it.vpnActive }
?.let { result ->
@ -721,6 +745,20 @@ object DirectSignsChecker {
),
)
detected = true
// Per-app OS-device-binding probe: both paths reachable with different IPs.
// This is the strongest signal for per-app whitelist VPN with excluded app.
if (result.underlyingReachable) {
evidence.add(
EvidenceItem(
source = EvidenceSource.TUN_ACTIVE_PROBE,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "Per-app VPN exclusion confirmed: tun/underlying IP mismatch (${target.targetGroup}): vpn=$vpnIp underlying=${target.directIp}",
kind = com.notcvnt.rknhardering.model.VpnAppKind.TARGETED_BYPASS,
),
)
}
} else {
evidence.add(
EvidenceItem(

View file

@ -19,8 +19,12 @@ import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.WhitelistAwareDnsFailureCounter
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbe
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.TunProbeModeOverride
import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.checker.TunInterfaceInfo
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -82,17 +86,33 @@ object VpnCheckRunner {
DnsResolverConfig,
Boolean,
TunProbeModeOverride,
Boolean,
String?,
String?,
) -> UnderlyingNetworkProber.ProbeResult =
{ ctx, resolverConfig, debugEnabled, modeOverride ->
{ ctx, resolverConfig, debugEnabled, modeOverride, tunInterfacePresent, tunInterfaceName, underlyingInterfaceName ->
UnderlyingNetworkProber.probe(
context = ctx,
resolverConfig = resolverConfig,
debugEnabled = debugEnabled,
modeOverride = modeOverride,
tunInterfacePresent = tunInterfacePresent,
tunInterfaceName = tunInterfaceName,
underlyingInterfaceName = underlyingInterfaceName,
)
},
val directCheck: suspend (Context, UnderlyingNetworkProber.ProbeResult?) -> CategoryResult =
{ ctx, tunActiveProbeResult -> DirectSignsChecker.check(ctx, tunActiveProbeResult = tunActiveProbeResult) },
val directCheck: suspend (Context, UnderlyingNetworkProber.ProbeResult?, Boolean) -> CategoryResult =
{ ctx, tunActiveProbeResult, tunInterfacePresent ->
DirectSignsChecker.check(
ctx,
tunActiveProbeResult = tunActiveProbeResult,
tunInterfacePresent = tunInterfacePresent,
)
},
val tunInterfaceInfoCollector: (Context) -> TunInterfaceInfo =
{ ctx -> IndirectSignsChecker.collectTunInterfaceInfo(ctx) },
val operatorWhitelistProbe: suspend () -> OperatorWhitelistProbeResult =
{ OperatorWhitelistProbe.probe() },
val indirectCheck: suspend (Context, Boolean, Boolean, DnsResolverConfig) -> CategoryResult =
{ ctx, networkRequestsEnabled, callTransportProbeEnabled, resolverConfig ->
IndirectSignsChecker.check(
@ -318,6 +338,7 @@ object VpnCheckRunner {
executionContext: ScanExecutionContext = ScanExecutionContext(),
onUpdate: (suspend (CheckUpdate) -> Unit)? = null,
): CheckResult = withContext(executionContext.asCoroutineContext()) {
WhitelistAwareDnsFailureCounter.reset()
executionContext.throwIfCancelled()
supervisorScope {
val dependencies = dependenciesOverride ?: Dependencies()
@ -352,6 +373,22 @@ object VpnCheckRunner {
}
} else null
val tunInterfaceInfo = runCatching {
dependencies.tunInterfaceInfoCollector(context)
}.getOrElse {
TunInterfaceInfo(
tunInterfacePresent = false,
tunInterfaceName = null,
underlyingInterfaceName = null,
)
}
val operatorWhitelistDeferred = if (settings.networkRequestsEnabled) {
safeAsync<OperatorWhitelistProbeResult?>(fallback = { null }) {
dependencies.operatorWhitelistProbe()
}
} else null
val tunActiveProbeDeferred = if (settings.splitTunnelEnabled) {
safeAsync(fallback = { Fallbacks.probe(it) }) {
dependencies.underlyingProbe(
@ -359,6 +396,9 @@ object VpnCheckRunner {
settings.resolverConfig,
settings.tunProbeDebugEnabled,
settings.tunProbeModeOverride,
tunInterfaceInfo.tunInterfacePresent,
tunInterfaceInfo.tunInterfaceName,
tunInterfaceInfo.underlyingInterfaceName,
)
}
} else null
@ -367,6 +407,7 @@ object VpnCheckRunner {
dependencies.directCheck(
context,
tunActiveProbeDeferred?.await(),
tunInterfaceInfo.tunInterfacePresent,
)
}
val indirectDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.indirect(context, it) }) {
@ -574,6 +615,8 @@ object VpnCheckRunner {
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.VerdictReady(verdict))
val operatorWhitelistResult = operatorWhitelistDeferred?.await()
CheckResult(
geoIp = geoIp,
ipComparison = ipComparison,
@ -588,6 +631,7 @@ object VpnCheckRunner {
icmpSpoofing = icmpSpoofing,
rttTriangulation = rttTriangulation,
ipConsensus = ipConsensus,
operatorWhitelistProbe = operatorWhitelistResult,
)
}
}

View file

@ -1,5 +1,6 @@
package com.notcvnt.rknhardering.model
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.TunProbeDiagnostics
import com.notcvnt.rknhardering.probe.XrayApiScanResult
@ -386,4 +387,5 @@ data class CheckResult(
findings = emptyList(),
),
val ipConsensus: IpConsensusResult = IpConsensusResult.empty(),
val operatorWhitelistProbe: OperatorWhitelistProbeResult? = null,
)

View file

@ -52,6 +52,7 @@ internal data class ResolverHttpRequest(
object ResolverNetworkStack {
internal const val OKHTTP_RETRY_COUNT = 1
internal const val NATIVE_CURL_RETRY_COUNT = 1
internal const val YANDEX_DOH_URL = "https://common.dot.dns.yandex.net/dns-query"
private val lock = Any()
@Volatile
@ -84,6 +85,7 @@ object ResolverNetworkStack {
}
okHttpExecuteOverride = null
ResolverSocketBinder.resetForTests()
WhitelistAwareDnsFailureCounter.reset()
}
fun execute(
@ -295,7 +297,12 @@ object ResolverNetworkStack {
if (binding is ResolverBinding.OsDeviceBinding && binding.dnsMode == ResolverBinding.DnsMode.SYSTEM) {
return Dns.SYSTEM
}
return when (config.mode) {
if (WhitelistAwareDnsFailureCounter.dnsExhausted) {
return createYandexDohFallback(binding, cancellationSignal)
}
val baseDns = when (config.mode) {
DnsResolverMode.SYSTEM -> fallbackDns(binding)
DnsResolverMode.DIRECT -> {
val servers = config.effectiveDirectServers()
@ -334,6 +341,43 @@ object ResolverNetworkStack {
if (cancellationSignal != null) CancellableDns(doh, bootstrapClient, cancellationSignal) else doh
}
}
return CountingDns(baseDns)
}
private fun createYandexDohFallback(
binding: ResolverBinding?,
cancellationSignal: ScanCancellationSignal?,
): Dns {
val yandexBootstrapIps = listOf("77.88.8.8", "77.88.8.1", "77.88.8.88")
.mapNotNull { literalToInetAddress(it) }
val bootstrapClient = OkHttpClient.Builder()
.apply {
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> {
socketFactory(binding.network.socketFactory)
dns(NetworkDns(binding.network))
}
is ResolverBinding.OsDeviceBinding -> {
socketFactory(BindToDeviceSocketFactory(binding.interfaceName))
dns(Dns.SYSTEM)
}
null -> Unit
}
}
.build()
return try {
val builder = DnsOverHttps.Builder()
.client(bootstrapClient)
.url(YANDEX_DOH_URL.toHttpUrl())
if (yandexBootstrapIps.isNotEmpty()) {
builder.bootstrapDnsHosts(yandexBootstrapIps)
}
val doh = builder.build()
if (cancellationSignal != null) CancellableDns(doh, bootstrapClient, cancellationSignal) else doh
} catch (e: Exception) {
// Yandex DoH itself failed — fall back to direct Yandex servers
DirectDns(listOf("77.88.8.8", "77.88.8.1"), binding = binding, cancellationSignal = cancellationSignal)
}
}
private fun literalToInetAddress(value: String): InetAddress? {
@ -633,3 +677,21 @@ internal class DirectDns(
return ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
}
}
internal class CountingDns(
private val delegate: Dns,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return try {
delegate.lookup(hostname)
} catch (e: UnknownHostException) {
WhitelistAwareDnsFailureCounter.recordFailure()
throw e
} catch (e: IOException) {
if (e.message?.contains("No address associated with hostname") == true) {
WhitelistAwareDnsFailureCounter.recordFailure()
}
throw e
}
}
}

View file

@ -0,0 +1,25 @@
package com.notcvnt.rknhardering.network
import java.util.concurrent.atomic.AtomicInteger
object WhitelistAwareDnsFailureCounter {
private const val THRESHOLD = 3
private val count = AtomicInteger(0)
@Volatile
var dnsExhausted: Boolean = false
private set
fun reset() {
count.set(0)
dnsExhausted = false
}
fun recordFailure() {
val current = count.incrementAndGet()
if (current >= THRESHOLD) {
dnsExhausted = true
}
}
}

View file

@ -203,6 +203,68 @@ object IfconfigClient {
)
}
// Per-app VPN exclusion probe: no Android Network object available, bind via
// OS SO_BINDTODEVICE directly. CURL_COMPATIBLE (NativeCurl) is the only viable
// transport since the strict path needs AndroidNetworkBinding.
suspend fun fetchIpViaOsDeviceBinding(
binding: ResolverBinding.OsDeviceBinding,
timeoutMs: Int = DEFAULT_HTTP_TIMEOUT_MS,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
modeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
collectTrace: Boolean = false,
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 (!targetUrls.isNullOrEmpty()) {
targetUrls.map(::IpEndpointSpec)
} else {
ENDPOINTS
}
// Strict same-path requires AndroidNetworkBinding; with only OsDeviceBinding available
// it is always skipped. Curl-compatible (NativeCurl) uses SO_BINDTODEVICE which works.
val strict = PublicIpModeProbeResult(
mode = PublicIpProbeMode.STRICT_SAME_PATH,
status = PublicIpProbeStatus.SKIPPED,
error = "AndroidNetworkBinding not available for per-app excluded interface",
)
val curlCompatible = if (modeOverride == TunProbeModeOverride.STRICT_SAME_PATH) {
PublicIpModeProbeResult(
mode = PublicIpProbeMode.CURL_COMPATIBLE,
status = PublicIpProbeStatus.SKIPPED,
error = DISABLED_BY_OVERRIDE_MESSAGE,
)
} else {
fetchModeProbeResult(
mode = PublicIpProbeMode.CURL_COMPATIBLE,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
binding = binding,
collectTrace = collectTrace,
endpoints = endpoints,
okHttpRetryCount = okHttpRetryCount,
nativeCurlRetryCount = nativeCurlRetryCount,
executionContext = executionContext,
)
}
val selectedMode = when {
curlCompatible.status == PublicIpProbeStatus.SUCCEEDED -> PublicIpProbeMode.CURL_COMPATIBLE
else -> null
}
val selectedIp = curlCompatible.ip.takeIf { selectedMode != null }
val selectedError = if (selectedIp != null) null else curlCompatible.error ?: strict.error
PublicIpNetworkComparison(
strict = strict,
curlCompatible = curlCompatible,
selectedMode = selectedMode,
selectedIp = selectedIp,
selectedError = selectedError,
dnsPathMismatch = false,
)
}
private suspend fun fetchIpWithFallback(
timeoutMs: Int,
resolverConfig: DnsResolverConfig,

View file

@ -0,0 +1,133 @@
package com.notcvnt.rknhardering.probe
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.dnsoverhttps.DnsOverHttps
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.net.InetAddress
import java.util.concurrent.TimeUnit
object OperatorWhitelistProbe {
private const val REQUEST_TIMEOUT_MS = 4_000L
private const val PROBE_TIMEOUT_MS = 6_000L
private val YANDEX_DOH_URL = "https://common.dot.dns.yandex.net/dns-query"
private val YANDEX_BOOTSTRAP_IPS = listOf("77.88.8.8", "77.88.8.1", "77.88.8.88")
@Volatile
internal var executeOverride: ((String, String) -> Pair<Int, String>)? = null
suspend fun probe(): OperatorWhitelistProbeResult {
val startMs = System.currentTimeMillis()
val errors = mutableMapOf<String, String>()
val (googleOk, appleOk, firefoxOk, ruOk) = withTimeout(PROBE_TIMEOUT_MS) {
coroutineScope {
val googleDeferred = async { checkGoogle(errors) }
val appleDeferred = async { checkApple(errors) }
val firefoxDeferred = async { checkFirefox(errors) }
val ruDeferred = async { checkRussianControl(errors) }
arrayOf(
googleDeferred.await(),
appleDeferred.await(),
firefoxDeferred.await(),
ruDeferred.await(),
)
}
}
val allCaptiveFailed = !googleOk && !appleOk && !firefoxOk
val whitelistDetected = allCaptiveFailed && ruOk
return OperatorWhitelistProbeResult(
whitelistDetected = whitelistDetected,
googleReachable = googleOk,
appleReachable = appleOk,
firefoxReachable = firefoxOk,
russianControlReachable = ruOk,
errors = errors,
durationMs = System.currentTimeMillis() - startMs,
)
}
private fun buildClient(): OkHttpClient {
val bootstrapAddresses = YANDEX_BOOTSTRAP_IPS.mapNotNull { ip ->
runCatching { InetAddress.getByName(ip) }.getOrNull()
}
val baseClient = OkHttpClient.Builder().build()
val doh = DnsOverHttps.Builder()
.client(baseClient)
.url(YANDEX_DOH_URL.toHttpUrl())
.apply {
if (bootstrapAddresses.isNotEmpty()) {
bootstrapDnsHosts(bootstrapAddresses)
}
}
.build()
return OkHttpClient.Builder()
.dns(doh)
.connectTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
}
private fun doRequest(url: String, method: String = "GET"): Pair<Int, String> {
executeOverride?.let { return it(url, method) }
val client = buildClient()
val request = Request.Builder()
.url(url)
.method(method, null)
.build()
client.newCall(request).execute().use { response ->
return response.code to (response.body?.string().orEmpty())
}
}
private fun checkGoogle(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, body) = doRequest("https://www.google.com/generate_204")
code == 204 && body.isEmpty()
}.getOrElse { e ->
errors["google"] = e.message ?: e.javaClass.simpleName
false
}
}
private fun checkApple(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, body) = doRequest("https://www.apple.com/library/test/success.html")
code == 200 &&
body.contains("<TITLE>Success</TITLE>", ignoreCase = false) &&
body.contains("<BODY>Success</BODY>", ignoreCase = false)
}.getOrElse { e ->
errors["apple"] = e.message ?: e.javaClass.simpleName
false
}
}
private fun checkFirefox(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, body) = doRequest("https://detectportal.firefox.com/success.txt")
code == 200 && body.startsWith("success")
}.getOrElse { e ->
errors["firefox"] = e.message ?: e.javaClass.simpleName
false
}
}
private fun checkRussianControl(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, _) = doRequest("https://yandex.ru/", method = "HEAD")
code in 200..399
}.getOrElse { e ->
errors["yandex_ru"] = e.message ?: e.javaClass.simpleName
false
}
}
}

View file

@ -0,0 +1,11 @@
package com.notcvnt.rknhardering.probe
data class OperatorWhitelistProbeResult(
val whitelistDetected: Boolean,
val googleReachable: Boolean,
val appleReachable: Boolean,
val firefoxReachable: Boolean,
val russianControlReachable: Boolean,
val errors: Map<String, String>,
val durationMs: Long,
)

View file

@ -65,6 +65,14 @@ object UnderlyingNetworkProber {
TunProbeModeOverride,
List<String>?,
) -> PublicIpNetworkComparison = ::fetchIpViaNetworkComparison,
// Used when no Android network object is available (per-app VPN exclusion case).
val osDeviceComparisonFetcher: suspend (
String,
DnsResolverConfig,
Boolean,
TunProbeModeOverride,
List<String>?,
) -> PublicIpNetworkComparison = ::fetchIpViaOsDeviceBinding,
)
@Volatile
@ -129,6 +137,9 @@ object UnderlyingNetworkProber {
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
debugEnabled: Boolean = false,
modeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
tunInterfacePresent: Boolean = false,
tunInterfaceName: String? = null,
underlyingInterfaceName: String? = null,
): ProbeResult = withContext(Dispatchers.IO) {
val dependencies = dependenciesOverride ?: Dependencies()
dependencies.initNativeCurl(context)
@ -142,6 +153,20 @@ object UnderlyingNetworkProber {
val nonVpnNetworks = internetNetworks.filterNot { it.hasVpnTransport }
if (vpnNetwork == null) {
// Per-app whitelist fallback: tun0 is physically present but excluded app
// cannot see it in cm.allNetworks because per-app VPN does not expose the VPN
// network to excluded packages. Use OS device binding directly on tun interface.
if (tunInterfacePresent && tunInterfaceName != null) {
return@withContext probeViaOsDeviceBinding(
tunInterfaceName = tunInterfaceName,
underlyingInterfaceName = underlyingInterfaceName,
resolverConfig = resolverConfig,
debugEnabled = debugEnabled,
modeOverride = modeOverride,
activeNetworkIsVpn = activeNetworkIsVpn,
dependencies = dependencies,
)
}
return@withContext ProbeResult(
vpnActive = false,
underlyingReachable = false,
@ -287,6 +312,172 @@ object UnderlyingNetworkProber {
)
}
// Yandex DoH used for per-app fallback probes so that DNS goes through
// CONFIGURED resolver and not the operator system DNS (which is in the
// tunnel and unreachable for excluded apps).
private val FALLBACK_RESOLVER_CONFIG = DnsResolverConfig(
mode = com.notcvnt.rknhardering.network.DnsResolverMode.DOH,
preset = com.notcvnt.rknhardering.network.DnsResolverPreset.YANDEX,
)
private suspend fun probeViaOsDeviceBinding(
tunInterfaceName: String,
underlyingInterfaceName: String?,
resolverConfig: DnsResolverConfig,
debugEnabled: Boolean,
modeOverride: TunProbeModeOverride,
activeNetworkIsVpn: Boolean?,
dependencies: Dependencies,
): ProbeResult = coroutineScope {
// Pick a resolver that works for an excluded app: fall back to Yandex DoH
// only if the caller provided system() — system DNS is inside the tunnel and
// inaccessible from an excluded package.
val effectiveResolver = if (resolverConfig.mode == com.notcvnt.rknhardering.network.DnsResolverMode.SYSTEM) {
FALLBACK_RESOLVER_CONFIG
} else {
resolverConfig
}
val tunComparisons = fetchOsDeviceTargetComparisons(
interfaceName = tunInterfaceName,
dependencies = dependencies,
resolverConfig = effectiveResolver,
debugEnabled = debugEnabled,
modeOverride = modeOverride,
)
val tunRuComparison = tunComparisons.ru
val tunNonRuComparison = tunComparisons.nonRu
val tunRuProbe = PerTargetProbe(
targetHost = RU_PROBE_TARGET.displayHost,
targetGroup = com.notcvnt.rknhardering.model.TargetGroup.RU,
vpnIp = tunRuComparison.selectedIp,
comparison = tunRuComparison,
error = tunRuComparison.selectedError,
)
val tunNonRuProbe = PerTargetProbe(
targetHost = NON_RU_PROBE_TARGET.displayHost,
targetGroup = com.notcvnt.rknhardering.model.TargetGroup.NON_RU,
vpnIp = tunNonRuComparison.selectedIp,
comparison = tunNonRuComparison,
error = tunNonRuComparison.selectedError,
)
// If no underlying interface is provided, we can only confirm tun is reachable
// but cannot compare IPs to determine leak.
if (underlyingInterfaceName == null) {
val tunReachable = tunRuComparison.selectedIp != null || tunNonRuComparison.selectedIp != null
return@coroutineScope ProbeResult(
vpnActive = tunReachable,
underlyingReachable = false,
ruTarget = tunRuProbe,
nonRuTarget = tunNonRuProbe,
vpnError = tunRuComparison.selectedError ?: tunNonRuComparison.selectedError,
dnsPathMismatch = tunRuComparison.dnsPathMismatch || tunNonRuComparison.dnsPathMismatch,
activeNetworkIsVpn = activeNetworkIsVpn,
tunProbeDiagnostics = buildTunProbeDiagnostics(
debugEnabled = debugEnabled,
modeOverride = modeOverride,
activeNetworkIsVpn = activeNetworkIsVpn,
vpnNetworkPresent = false,
underlyingNetworkPresent = false,
vpnInterfaceName = tunInterfaceName,
vpnComparison = tunRuComparison,
underlyingInterfaceName = null,
underlyingComparison = null,
),
)
}
val underlyingComparisons = fetchOsDeviceTargetComparisons(
interfaceName = underlyingInterfaceName,
dependencies = dependencies,
resolverConfig = effectiveResolver,
debugEnabled = debugEnabled,
modeOverride = modeOverride,
)
val underlyingRuComparison = underlyingComparisons.ru
val underlyingNonRuComparison = underlyingComparisons.nonRu
val tunRuIp = tunRuComparison.selectedIp
val tunNonRuIp = tunNonRuComparison.selectedIp
val underlyingRuIp = underlyingRuComparison.selectedIp
val underlyingNonRuIp = underlyingNonRuComparison.selectedIp
val tunReachable = tunRuIp != null || tunNonRuIp != null
val underlyingReachable = underlyingRuIp != null || underlyingNonRuIp != null
// IPs differ between tun and underlying path → split-tunnel leak confirmed.
val ipsDiffer = (tunRuIp != null && underlyingRuIp != null && tunRuIp != underlyingRuIp) ||
(tunNonRuIp != null && underlyingNonRuIp != null && tunNonRuIp != underlyingNonRuIp)
val finalRuProbe = tunRuProbe.copy(
directIp = underlyingRuIp,
comparison = underlyingRuComparison,
)
val finalNonRuProbe = tunNonRuProbe.copy(
directIp = underlyingNonRuIp,
comparison = underlyingNonRuComparison,
)
ProbeResult(
vpnActive = tunReachable,
underlyingReachable = underlyingReachable,
ruTarget = finalRuProbe,
nonRuTarget = finalNonRuProbe,
vpnError = tunRuComparison.selectedError ?: tunNonRuComparison.selectedError,
underlyingError = underlyingRuComparison.selectedError ?: underlyingNonRuComparison.selectedError,
dnsPathMismatch = ipsDiffer ||
tunRuComparison.dnsPathMismatch || tunNonRuComparison.dnsPathMismatch ||
underlyingRuComparison.dnsPathMismatch || underlyingNonRuComparison.dnsPathMismatch,
activeNetworkIsVpn = activeNetworkIsVpn,
tunProbeDiagnostics = buildTunProbeDiagnostics(
debugEnabled = debugEnabled,
modeOverride = modeOverride,
activeNetworkIsVpn = activeNetworkIsVpn,
vpnNetworkPresent = false,
underlyingNetworkPresent = underlyingReachable,
vpnInterfaceName = tunInterfaceName,
vpnComparison = tunRuComparison,
underlyingInterfaceName = underlyingInterfaceName,
underlyingComparison = underlyingRuComparison,
),
)
}
private suspend fun fetchOsDeviceTargetComparisons(
interfaceName: String,
dependencies: Dependencies,
resolverConfig: DnsResolverConfig,
debugEnabled: Boolean,
modeOverride: TunProbeModeOverride,
): TargetComparisons = coroutineScope {
val ruDeferred = async {
dependencies.osDeviceComparisonFetcher(
interfaceName,
resolverConfig,
debugEnabled,
modeOverride,
RU_PROBE_TARGET.urls,
)
}
val nonRuDeferred = async {
dependencies.osDeviceComparisonFetcher(
interfaceName,
resolverConfig,
debugEnabled,
modeOverride,
NON_RU_PROBE_TARGET.urls,
)
}
TargetComparisons(
ru = ruDeferred.await(),
nonRu = nonRuDeferred.await(),
)
}
private suspend fun fetchTargetComparisons(
snapshot: NetworkSnapshot,
dependencies: Dependencies,
@ -346,7 +537,7 @@ object UnderlyingNetworkProber {
): PublicIpNetworkComparison {
val fallbackBinding = networkSnapshot.interfaceName
?.takeIf { it.isNotBlank() }
?.let { ResolverBinding.OsDeviceBinding(it, dnsMode = ResolverBinding.DnsMode.SYSTEM) }
?.let { ResolverBinding.OsDeviceBinding(it, dnsMode = ResolverBinding.DnsMode.CONFIGURED) }
return IfconfigClient.fetchIpViaNetworkComparison(
primaryBinding = ResolverBinding.AndroidNetworkBinding(networkSnapshot.network),
@ -358,6 +549,28 @@ object UnderlyingNetworkProber {
)
}
private suspend fun fetchIpViaOsDeviceBinding(
interfaceName: String,
resolverConfig: DnsResolverConfig,
debugEnabled: Boolean,
modeOverride: TunProbeModeOverride,
targetUrls: List<String>? = null,
): PublicIpNetworkComparison {
// OsDeviceBinding with CONFIGURED mode: DNS resolved via config (DoH/direct),
// not the system DNS that may be unreachable inside a per-app excluded tunnel.
val binding = ResolverBinding.OsDeviceBinding(
interfaceName = interfaceName,
dnsMode = ResolverBinding.DnsMode.CONFIGURED,
)
return IfconfigClient.fetchIpViaOsDeviceBinding(
binding = binding,
resolverConfig = resolverConfig,
modeOverride = modeOverride,
collectTrace = debugEnabled,
targetUrls = targetUrls,
)
}
internal fun resetForTests() {
dependenciesOverride = null
}

View file

@ -126,5 +126,36 @@
android:visibility="gone"
tools:text="@string/narrative_home_routed_roaming_generic"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/whitelistWarningBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_verdict_home_routed_roaming_note"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="10dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_warning"
app:tint="?attr/colorOnSurface" />
<TextView
android:id="@+id/whitelistWarningText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/whitelist_warning_banner"
android:textColor="?android:attr/textColorPrimary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -333,6 +333,9 @@
<string name="narrative_home_routed_roaming_likely_country">Иностранная SIM (%1$s) подключена в гостевую российскую сеть. Скорее всего, дата-сессия выходит в интернет в домашней стране — это легитимный сценарий, который выглядит как обход.</string>
<string name="narrative_home_routed_roaming_generic">Иностранная SIM подключена в гостевую российскую сеть. Дата-сессия может законно выходить в интернет за рубежом (home-routed роуминг).</string>
<string name="whitelist_warning_banner">Вы выполняете проверку в белых списках, результат может быть искажён</string>
<string name="narrative_whitelist_note">Скан выполнен в режиме whitelist оператора, результаты могут быть неполными</string>
<!-- Checker strings -->
<string name="checker_yes">да</string>
<string name="checker_no">нет</string>
@ -416,6 +419,7 @@
<string name="checker_direct_proxyinfo_invalid">%1$s: невалидный профиль proxy</string>
<string name="checker_direct_proxyinfo_unavailable">%1$s: проверка ProxyInfo недоступна (%2$s)</string>
<string name="checker_direct_proxyinfo_network_none">ProxyInfo на отслеживаемых сетях: не обнаружен</string>
<string name="checker_direct_per_app_vpn_excluded">Приложение исключено из активного per-app VPN (tun0 присутствует, но недоступен из приложения)</string>
<!-- IndirectSignsChecker -->
<string name="checker_indirect_category_name">Косвенные признаки</string>

View file

@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">RKNHardering</string>
<string name="github_repo_url">https://github.com/xtclovver/RKNHardering</string>
<string name="matrix_room_url">https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech</string>
@ -330,6 +330,9 @@
<string name="narrative_home_routed_roaming_likely_country">A foreign SIM (%1$s) is connected via a Russian visited network. The data session likely exits via the home country, which can look like a bypass.</string>
<string name="narrative_home_routed_roaming_generic">A foreign SIM is connected via a Russian visited network. The data session may legitimately exit abroad (home-routed roaming).</string>
<string name="whitelist_warning_banner" tools:ignore="MissingTranslation">You are scanning under operator whitelist mode — result may be distorted</string>
<string name="narrative_whitelist_note" tools:ignore="MissingTranslation">Scan performed under operator whitelist mode — results may be incomplete</string>
<!-- Checker strings -->
<string name="checker_yes">yes</string>
<string name="checker_no">no</string>
@ -413,6 +416,7 @@
<string name="checker_direct_proxyinfo_invalid">%1$s: invalid proxy profile</string>
<string name="checker_direct_proxyinfo_unavailable">%1$s: ProxyInfo check unavailable (%2$s)</string>
<string name="checker_direct_proxyinfo_network_none">ProxyInfo on tracked networks: not detected</string>
<string name="checker_direct_per_app_vpn_excluded" tools:ignore="MissingTranslation">App is excluded from active per-app VPN (tun0 present but not accessible to this app)</string>
<!-- IndirectSignsChecker -->
<string name="checker_indirect_category_name">Indirect signs</string>

View file

@ -3,6 +3,7 @@ package com.notcvnt.rknhardering
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -279,4 +280,61 @@ class CheckResultJsonExportFormatterTest {
assertEquals("1.2.3", metadata.getString("versionName"))
assertEquals("ExampleService", metadata.getJSONArray("serviceNames").getString(0))
}
@Test
fun `json export includes operator_whitelist section when probe present with whitelistDetected true`() {
val base = exportEmptyCheckResult()
val json = JSONObject(
CheckResultJsonExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = base.copy(
operatorWhitelistProbe = OperatorWhitelistProbeResult(
whitelistDetected = true,
googleReachable = false,
appleReachable = false,
firefoxReachable = false,
russianControlReachable = true,
errors = mapOf("google" to "timeout", "apple" to "refused"),
durationMs = 4521L,
),
),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
),
)
assertTrue(json.has("operator_whitelist"))
val wl = json.getJSONObject("operator_whitelist")
assertTrue(wl.getBoolean("detected"))
assertFalse(wl.getBoolean("google_reachable"))
assertFalse(wl.getBoolean("apple_reachable"))
assertFalse(wl.getBoolean("firefox_reachable"))
assertTrue(wl.getBoolean("russian_control_reachable"))
assertEquals(4521L, wl.getLong("duration_ms"))
val errors = wl.getJSONObject("errors")
assertEquals("timeout", errors.getString("google"))
assertEquals("refused", errors.getString("apple"))
}
@Test
fun `json export omits operator_whitelist section when probe is null`() {
val json = JSONObject(
CheckResultJsonExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = exportEmptyCheckResult(),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
),
)
assertFalse(json.has("operator_whitelist"))
}
}

View file

@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.UnparsedIp
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ -223,4 +224,51 @@ class CheckResultMarkdownExportFormatterTest {
assertTrue(markdown.contains("## TUN probe diagnostics"))
assertTrue(markdown.contains("- Selected IP: 203.0.113.64"))
}
@Test
fun `markdown export includes operator whitelist section when probe present`() {
val base = exportEmptyCheckResult()
val markdown = CheckResultMarkdownExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = base.copy(
operatorWhitelistProbe = OperatorWhitelistProbeResult(
whitelistDetected = true,
googleReachable = false,
appleReachable = false,
firefoxReachable = false,
russianControlReachable = true,
errors = emptyMap(),
durationMs = 3000L,
),
),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
)
assertTrue(markdown.contains("## Белые списки оператора"))
assertTrue(markdown.contains("Детектировано: да"))
assertTrue(markdown.contains("google.com/generate_204: недоступен"))
assertTrue(markdown.contains("yandex.ru (контроль): доступен"))
assertTrue(markdown.contains("Длительность: 3000 мс"))
}
@Test
fun `markdown export omits operator whitelist section when probe is null`() {
val markdown = CheckResultMarkdownExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = exportEmptyCheckResult(),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
)
assertFalse(markdown.contains("## Белые списки оператора"))
}
}

View file

@ -37,6 +37,7 @@ import com.notcvnt.rknhardering.model.VpnAppKind
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.DnsResolverMode
import com.notcvnt.rknhardering.model.StunScope
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.ProxyType
import com.notcvnt.rknhardering.probe.XrayApiEndpoint
@ -650,4 +651,118 @@ class DebugDiagnosticsFormatterTest {
assertTrue(report.contains("scope=GLOBAL"))
assertTrue(report.contains("name=probeStunTargets durationMs=30000 skipped=false"))
}
@Test
fun `formatter includes operatorWhitelist section with errors when probe present`() {
val base = CheckResult(
geoIp = CategoryResult(name = "GeoIP", detected = false, findings = emptyList()),
ipComparison = IpComparisonResult(
detected = false,
summary = "",
ruGroup = IpCheckerGroupResult(
title = "RU",
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
),
nonRuGroup = IpCheckerGroupResult(
title = "NON_RU",
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
),
),
directSigns = CategoryResult(name = "Direct", detected = false, findings = emptyList()),
indirectSigns = CategoryResult(name = "Indirect", detected = false, findings = emptyList()),
locationSignals = CategoryResult(name = "Location", detected = false, findings = emptyList()),
bypassResult = BypassResult(
proxyEndpoint = null,
directIp = null,
proxyIp = null,
xrayApiScanResult = null,
findings = emptyList(),
detected = false,
),
verdict = Verdict.NOT_DETECTED,
operatorWhitelistProbe = OperatorWhitelistProbeResult(
whitelistDetected = true,
googleReachable = false,
appleReachable = false,
firefoxReachable = false,
russianControlReachable = true,
errors = mapOf("google" to "timeout", "apple" to "connection refused"),
durationMs = 4521L,
),
)
val report = DebugDiagnosticsFormatter.format(
result = base,
settings = CheckSettings(tunProbeDebugEnabled = false),
privacyMode = false,
timestampMillis = 0L,
appVersionName = "1.0",
buildType = "debug",
)
assertTrue(report.contains("[operatorWhitelist]"))
assertTrue(report.contains("collected: true"))
assertTrue(report.contains("detected: true"))
assertTrue(report.contains("googleReachable: false"))
assertTrue(report.contains("russianControlReachable: true"))
assertTrue(report.contains("durationMs: 4521"))
assertTrue(report.contains("- google: timeout"))
assertTrue(report.contains("- apple: connection refused"))
}
@Test
fun `formatter reports operatorWhitelist not collected when probe is null`() {
val base = CheckResult(
geoIp = CategoryResult(name = "GeoIP", detected = false, findings = emptyList()),
ipComparison = IpComparisonResult(
detected = false,
summary = "",
ruGroup = IpCheckerGroupResult(
title = "RU",
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
),
nonRuGroup = IpCheckerGroupResult(
title = "NON_RU",
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
),
),
directSigns = CategoryResult(name = "Direct", detected = false, findings = emptyList()),
indirectSigns = CategoryResult(name = "Indirect", detected = false, findings = emptyList()),
locationSignals = CategoryResult(name = "Location", detected = false, findings = emptyList()),
bypassResult = BypassResult(
proxyEndpoint = null,
directIp = null,
proxyIp = null,
xrayApiScanResult = null,
findings = emptyList(),
detected = false,
),
verdict = Verdict.NOT_DETECTED,
)
val report = DebugDiagnosticsFormatter.format(
result = base,
settings = CheckSettings(tunProbeDebugEnabled = false),
privacyMode = false,
timestampMillis = 0L,
appVersionName = "1.0",
buildType = "debug",
)
assertTrue(report.contains("[operatorWhitelist]"))
assertTrue(report.contains("collected: false"))
assertFalse(report.contains("detected: true"))
}
}

View file

@ -21,6 +21,7 @@ import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpCheckerScope
import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.ProxyType
import com.notcvnt.rknhardering.probe.XrayApiEndpoint
@ -327,6 +328,54 @@ class VerdictNarrativeTest {
)
}
@Test
fun `whitelist note is set when whitelistDetected is true`() {
val narrative = VerdictNarrativeBuilder.build(
context = context,
result = result(
operatorWhitelistProbe = OperatorWhitelistProbeResult(
whitelistDetected = true,
googleReachable = false,
appleReachable = false,
firefoxReachable = false,
russianControlReachable = true,
errors = mapOf("google" to "timeout"),
durationMs = 1234L,
),
),
)
assertTrue(narrative.whitelistNote != null)
assertTrue(narrative.whitelistNote!!.isNotBlank())
}
@Test
fun `whitelist note is null when probe is absent`() {
val narrative = VerdictNarrativeBuilder.build(context = context, result = result())
assertTrue(narrative.whitelistNote == null)
}
@Test
fun `whitelist note is null when whitelistDetected is false`() {
val narrative = VerdictNarrativeBuilder.build(
context = context,
result = result(
operatorWhitelistProbe = OperatorWhitelistProbeResult(
whitelistDetected = false,
googleReachable = true,
appleReachable = true,
firefoxReachable = true,
russianControlReachable = true,
errors = emptyMap(),
durationMs = 500L,
),
),
)
assertTrue(narrative.whitelistNote == null)
}
private fun result(
verdict: Verdict = Verdict.NOT_DETECTED,
geoIp: CategoryResult = category(),
@ -335,6 +384,7 @@ class VerdictNarrativeTest {
location: CategoryResult = category(),
ipComparison: IpComparisonResult = ipComparison(),
bypass: BypassResult = bypass(),
operatorWhitelistProbe: OperatorWhitelistProbeResult? = null,
): CheckResult = CheckResult(
geoIp = geoIp,
ipComparison = ipComparison,
@ -343,6 +393,7 @@ class VerdictNarrativeTest {
locationSignals = location,
bypassResult = bypass,
verdict = verdict,
operatorWhitelistProbe = operatorWhitelistProbe,
)
private fun category(

View file

@ -15,6 +15,7 @@ import com.notcvnt.rknhardering.probe.TunProbeResolveStrategy
import com.notcvnt.rknhardering.model.TargetGroup
import com.notcvnt.rknhardering.probe.PerTargetProbe
import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.model.VpnAppKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -320,6 +321,87 @@ class DirectSignsCheckerTest {
)
}
@Test
fun `per-app vpn exclusion with tun0 present and vpnActive false produces TARGETED_BYPASS evidence`() {
val result = DirectSignsChecker.check(
context = context,
tunActiveProbeResult = UnderlyingNetworkProber.ProbeResult(
vpnActive = false,
underlyingReachable = false,
activeNetworkIsVpn = false,
),
tunInterfacePresent = true,
)
assertTrue(result.detected)
assertTrue(
result.findings.any {
it.detected &&
it.source == EvidenceSource.TUN_ACTIVE_PROBE &&
it.confidence == EvidenceConfidence.MEDIUM
},
)
assertTrue(
result.evidence.any {
it.detected &&
it.source == EvidenceSource.TUN_ACTIVE_PROBE &&
it.kind == VpnAppKind.TARGETED_BYPASS
},
)
}
@Test
fun `per-app vpn exclusion with tun0 and underlying probe differing IPs produces HIGH confidence evidence`() {
// Simulates probeViaOsDeviceBinding result: vpnActive=true, underlyingReachable=true,
// IP mismatch between tun and underlying → dnsPathMismatch=true.
val tunComparison = PublicIpNetworkComparison(
strict = PublicIpModeProbeResult(
mode = PublicIpProbeMode.STRICT_SAME_PATH,
status = PublicIpProbeStatus.SKIPPED,
error = "AndroidNetworkBinding not available for per-app excluded interface",
),
curlCompatible = PublicIpModeProbeResult(
mode = PublicIpProbeMode.CURL_COMPATIBLE,
status = PublicIpProbeStatus.SUCCEEDED,
ip = "10.8.0.1",
transportDiagnostics = PublicIpTransportDiagnostics(
resolveStrategy = TunProbeResolveStrategy.KOTLIN_INJECTED,
),
),
selectedMode = PublicIpProbeMode.CURL_COMPATIBLE,
selectedIp = "10.8.0.1",
dnsPathMismatch = true,
)
val result = DirectSignsChecker.check(
context = context,
tunActiveProbeResult = UnderlyingNetworkProber.ProbeResult(
vpnActive = true,
underlyingReachable = true,
ruTarget = PerTargetProbe(
targetHost = "ipv4-internet.yandex.net",
targetGroup = TargetGroup.RU,
vpnIp = "10.8.0.1",
directIp = "203.0.113.1",
comparison = tunComparison,
),
dnsPathMismatch = true,
activeNetworkIsVpn = false,
),
tunInterfacePresent = true,
)
assertTrue(result.detected)
assertTrue(
result.evidence.any {
it.detected &&
it.source == EvidenceSource.TUN_ACTIVE_PROBE &&
it.confidence == EvidenceConfidence.HIGH &&
it.kind == VpnAppKind.TARGETED_BYPASS
},
)
}
@Test
fun `check marks curl compatible tun probe success as detected and needs review`() {
val result = DirectSignsChecker.check(

View file

@ -71,7 +71,7 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, networkRequestsEnabled, callTransportProbeEnabled, _ ->
assertTrue(networkRequestsEnabled)
assertTrue(callTransportProbeEnabled)
@ -120,7 +120,7 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
bypassCheck = { _, _, splitTunnelEnabled, proxyScanEnabled, xrayApiScanEnabled, _, _, _, _, _ ->
@ -175,7 +175,7 @@ class VpnCheckRunnerTest {
),
)
},
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
bypassCheck = { _, _, _, _, _, _, _, _, _, _ ->
@ -214,7 +214,7 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> error("GeoIP should not run when network checks are disabled") },
ipComparisonCheck = { _, _ -> error("IP comparison should not run when network checks are disabled") },
directCheck = { _, _ ->
directCheck = { _, _, _ ->
category(
name = "direct",
evidence = listOf(
@ -265,7 +265,7 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
directCheck = { _, _ -> throw java.io.IOException("direct failed") },
directCheck = { _, _, _ -> throw java.io.IOException("direct failed") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
nativeCheck = { _ -> category("native") },
@ -293,7 +293,7 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> throw java.io.IOException("indirect failed") },
locationCheck = { _, _, _ -> category("location") },
nativeCheck = { _ -> category("native") },
@ -323,7 +323,7 @@ class VpnCheckRunnerTest {
ipComparisonCheck = { _, _ -> emptyIpComparison() },
icmpSpoofingCheck = { _, _ -> throw java.io.IOException("icmp failed") },
rttTriangulationCheck = { _, _, _ -> throw java.io.IOException("rtt failed") },
directCheck = { _, _ -> throw java.io.IOException("direct failed") },
directCheck = { _, _, _ -> throw java.io.IOException("direct failed") },
indirectCheck = { _, _, _, _ -> throw java.io.IOException("indirect failed") },
locationCheck = { _, _, _ -> throw java.io.IOException("location failed") },
nativeCheck = { _ -> throw java.io.IOException("native failed") },
@ -366,11 +366,11 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
underlyingProbe = { _, _, _, _ ->
underlyingProbe = { _, _, _, _, _, _, _ ->
probeCalls += 1
sharedProbe
},
directCheck = { _, tunActiveProbeResult ->
directCheck = { _, tunActiveProbeResult, _ ->
directProbeResult = tunActiveProbeResult
category("direct")
},
@ -411,7 +411,7 @@ class VpnCheckRunnerTest {
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ ->
indirectThread = Thread.currentThread()
category("indirect")
@ -466,7 +466,7 @@ class VpnCheckRunnerTest {
),
)
},
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
bypassCheck = { _, _, _, _, _, _, _, _, _, _ ->
@ -525,7 +525,7 @@ class VpnCheckRunnerTest {
ipComparisonCheck = { _, _ -> emptyIpComparison() },
cdnPullingCheck = { _, _, _ -> rawCdn },
icmpSpoofingCheck = { _, _ -> rawIcmp },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ ->
CategoryResult(
@ -577,7 +577,7 @@ class VpnCheckRunnerTest {
cdnCalls += 1
error("CDN pulling should not run when disabled")
},
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
bypassCheck = { _, _, _, _, _, _, _, _, _, _ ->
@ -625,7 +625,7 @@ class VpnCheckRunnerTest {
awaitRelease()
emptyIpComparison()
},
directCheck = { _, _ ->
directCheck = { _, _, _ ->
awaitRelease()
category("direct")
},
@ -677,7 +677,7 @@ class VpnCheckRunnerTest {
geoIpCheck = { _, _ -> throw java.io.IOException("boom") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
icmpSpoofingCheck = { _, _ -> category("icmp") },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
nativeCheck = { _ -> category("native") },
@ -707,7 +707,7 @@ class VpnCheckRunnerTest {
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> throw java.io.IOException("ip comparison failed") },
icmpSpoofingCheck = { _, _ -> category("icmp") },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
nativeCheck = { _ -> category("native") },
@ -737,7 +737,7 @@ class VpnCheckRunnerTest {
ipComparisonCheck = { _, _ -> emptyIpComparison() },
cdnPullingCheck = { _, _, _ -> throw java.io.IOException("cdn failed") },
icmpSpoofingCheck = { _, _ -> category("icmp") },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
nativeCheck = { _ -> category("native") },
@ -768,7 +768,7 @@ class VpnCheckRunnerTest {
geoIpCheck = { _, _ -> throw kotlinx.coroutines.CancellationException("stop") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
icmpSpoofingCheck = { _, _ -> category("icmp") },
directCheck = { _, _ -> category("direct") },
directCheck = { _, _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
nativeCheck = { _ -> category("native") },

View file

@ -70,11 +70,15 @@ class ResolverBindingTest {
customDohBootstrapHosts = listOf("1.1.1.1"),
)
val dns = ResolverNetworkStack.createDns(
val wrapped = ResolverNetworkStack.createDns(
config = config,
binding = ResolverBinding.OsDeviceBinding("tun0"),
)
val delegateField = wrapped.javaClass.getDeclaredField("delegate")
delegateField.isAccessible = true
val dns = delegateField.get(wrapped) as Dns
val clientField = dns.javaClass.getDeclaredField("client")
clientField.isAccessible = true
val client = clientField.get(dns) as OkHttpClient

View file

@ -10,20 +10,29 @@ import okhttp3.Dns
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class ResolverNetworkStackTest {
@Before
fun setUp() {
WhitelistAwareDnsFailureCounter.reset()
}
@After
fun tearDown() {
ResolverNetworkStack.dnsFactoryOverride = null
ResolverNetworkStack.resetForTests()
NativeCurlBridge.resetForTests()
WhitelistAwareDnsFailureCounter.reset()
}
@Test
@ -160,6 +169,48 @@ class ResolverNetworkStackTest {
assertTrue(error is CancellationException)
}
@Test
fun `after 3 dns failures createDns uses yandex doh regardless of config mode`() {
// Record 3 failures to exhaust
repeat(3) { WhitelistAwareDnsFailureCounter.recordFailure() }
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
var capturedDns: Dns? = null
ResolverNetworkStack.dnsFactoryOverride = { config, binding ->
// dnsFactoryOverride is called BEFORE the exhaustion guard in createDns,
// so to test the guard we need to test WITHOUT the override.
// We'll test by checking the DNS type produced without the override.
capturedDns = null
object : Dns {
override fun lookup(hostname: String): List<InetAddress> = emptyList()
}
}
// Reset override so we test the real path
ResolverNetworkStack.dnsFactoryOverride = null
// With SYSTEM mode config but dnsExhausted=true, createDns should not wrap in CountingDns
val systemConfig = DnsResolverConfig.system()
val dns = ResolverNetworkStack.createDns(systemConfig)
// The DNS returned must NOT be a CountingDns (which only wraps the normal path).
// When exhausted, we get Yandex DoH (DnsOverHttps) or DirectDns as fallback.
// Either way it's not CountingDns.
assertFalse("DNS must not be CountingDns when exhausted", dns is CountingDns)
assertNotNull(dns)
}
@Test
fun `createDns wraps with CountingDns when not exhausted`() {
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
ResolverNetworkStack.dnsFactoryOverride = null
val systemConfig = DnsResolverConfig.system()
val dns = ResolverNetworkStack.createDns(systemConfig)
assertTrue("DNS must be CountingDns when not exhausted", dns is CountingDns)
}
@Test
fun `native curl request is cancelled through registered callback`() {
val cancellationSignal = ScanCancellationSignal()

View file

@ -0,0 +1,140 @@
package com.notcvnt.rknhardering.network
import okhttp3.Dns
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.net.InetAddress
import java.net.UnknownHostException
class WhitelistAwareDnsFailureCounterTest {
@Before
fun setUp() {
WhitelistAwareDnsFailureCounter.reset()
}
@After
fun tearDown() {
WhitelistAwareDnsFailureCounter.reset()
ResolverNetworkStack.dnsFactoryOverride = null
ResolverNetworkStack.resetForTests()
}
@Test
fun `initial state is not exhausted`() {
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `two failures do not set exhausted`() {
WhitelistAwareDnsFailureCounter.recordFailure()
WhitelistAwareDnsFailureCounter.recordFailure()
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `three failures set exhausted`() {
WhitelistAwareDnsFailureCounter.recordFailure()
WhitelistAwareDnsFailureCounter.recordFailure()
WhitelistAwareDnsFailureCounter.recordFailure()
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `reset clears exhausted flag and allows re-counting`() {
repeat(3) { WhitelistAwareDnsFailureCounter.recordFailure() }
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
WhitelistAwareDnsFailureCounter.reset()
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
repeat(2) { WhitelistAwareDnsFailureCounter.recordFailure() }
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `counting dns increments on UnknownHostException`() {
val failingDns = object : Dns {
override fun lookup(hostname: String): List<InetAddress> =
throw UnknownHostException("NXDOMAIN: $hostname")
}
val countingDns = CountingDns(failingDns)
repeat(3) {
runCatching { countingDns.lookup("blocked.example.com") }
}
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `counting dns increments on no address IOException`() {
val failingDns = object : Dns {
override fun lookup(hostname: String): List<InetAddress> =
throw java.io.IOException("No address associated with hostname")
}
val countingDns = CountingDns(failingDns)
repeat(3) {
runCatching { countingDns.lookup("blocked.example.com") }
}
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `counting dns does not increment on unrelated IOException`() {
val failingDns = object : Dns {
override fun lookup(hostname: String): List<InetAddress> =
throw java.io.IOException("Connection timeout")
}
val countingDns = CountingDns(failingDns)
repeat(5) {
runCatching { countingDns.lookup("example.com") }
}
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `counting dns passes through successful lookups`() {
val addr = InetAddress.getByName("1.2.3.4")
val successDns = object : Dns {
override fun lookup(hostname: String): List<InetAddress> = listOf(addr)
}
val countingDns = CountingDns(successDns)
val result = countingDns.lookup("example.com")
assertEquals(listOf(addr), result)
assertFalse(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
@Test
fun `after 3 dns failures createDns returns yandex doh config`() {
repeat(3) { WhitelistAwareDnsFailureCounter.recordFailure() }
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
var resolvedConfig: DnsResolverConfig? = null
ResolverNetworkStack.dnsFactoryOverride = { config, _ ->
resolvedConfig = config
Dns.SYSTEM
}
// Even with SYSTEM mode, when exhausted it should use Yandex DoH path
// We verify via dnsExhausted flag that the guard is triggered
// (The factory override captures whatever config createDns would normally use)
// Since dnsFactoryOverride intercepts before the exhaustion check, we test differently:
ResolverNetworkStack.dnsFactoryOverride = null
// Verify that dnsExhausted causes createDns to skip the config.mode branch
// by observing that a new createDns call doesn't go through the CountingDns wrapper
// We can't easily test the DoH URL without network; verify the flag is set correctly.
assertTrue(WhitelistAwareDnsFailureCounter.dnsExhausted)
}
}

View file

@ -0,0 +1,199 @@
package com.notcvnt.rknhardering.probe
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.IOException
class OperatorWhitelistProbeTest {
// Per-URL response registry
private val responses = mutableMapOf<String, Pair<Int, String>>()
// Per-URL exception registry
private val exceptions = mutableMapOf<String, Throwable>()
@Before
fun setUp() {
responses.clear()
exceptions.clear()
OperatorWhitelistProbe.executeOverride = { url, _ ->
exceptions[url]?.let { throw it }
responses[url] ?: (200 to "")
}
}
@After
fun tearDown() {
OperatorWhitelistProbe.executeOverride = null
}
private fun setGoogleOk() {
responses["https://www.google.com/generate_204"] = 204 to ""
}
private fun setAppleOk() {
responses["https://www.apple.com/library/test/success.html"] =
200 to "<html><head><TITLE>Success</TITLE></head><BODY>Success</BODY></html>"
}
private fun setFirefoxOk() {
responses["https://detectportal.firefox.com/success.txt"] = 200 to "success\n"
}
private fun setRuOk() {
responses["https://yandex.ru/"] = 200 to ""
}
private fun setGoogleFail() {
exceptions["https://www.google.com/generate_204"] = IOException("blocked")
}
private fun setAppleFail() {
exceptions["https://www.apple.com/library/test/success.html"] = IOException("blocked")
}
private fun setFirefoxFail() {
exceptions["https://detectportal.firefox.com/success.txt"] = IOException("blocked")
}
private fun setRuFail() {
exceptions["https://yandex.ru/"] = IOException("network down")
}
@Test
fun `all three captive portals unreachable and RU works returns whitelistDetected true`() {
setGoogleFail()
setAppleFail()
setFirefoxFail()
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertTrue(result.whitelistDetected)
assertFalse(result.googleReachable)
assertFalse(result.appleReachable)
assertFalse(result.firefoxReachable)
assertTrue(result.russianControlReachable)
assertEquals(3, result.errors.size)
}
@Test
fun `all three captive portals reachable returns whitelistDetected false`() {
setGoogleOk()
setAppleOk()
setFirefoxOk()
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertFalse(result.whitelistDetected)
assertTrue(result.googleReachable)
assertTrue(result.appleReachable)
assertTrue(result.firefoxReachable)
assertTrue(result.russianControlReachable)
assertTrue(result.errors.isEmpty())
}
@Test
fun `all four endpoints unreachable returns whitelistDetected false`() {
setGoogleFail()
setAppleFail()
setFirefoxFail()
setRuFail()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertFalse(result.whitelistDetected)
assertFalse(result.googleReachable)
assertFalse(result.appleReachable)
assertFalse(result.firefoxReachable)
assertFalse(result.russianControlReachable)
assertEquals(4, result.errors.size)
}
@Test
fun `partial captive portal availability returns whitelistDetected false`() {
// Google blocked, Apple and Firefox reachable, RU reachable
setGoogleFail()
setAppleOk()
setFirefoxOk()
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertFalse(result.whitelistDetected)
assertFalse(result.googleReachable)
assertTrue(result.appleReachable)
assertTrue(result.firefoxReachable)
assertTrue(result.russianControlReachable)
}
@Test
fun `google wrong response code is not reachable`() {
responses["https://www.google.com/generate_204"] = 200 to "some body"
setAppleFail()
setFirefoxFail()
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertTrue(result.whitelistDetected)
assertFalse(result.googleReachable)
}
@Test
fun `apple wrong body content is not reachable`() {
setGoogleFail()
responses["https://www.apple.com/library/test/success.html"] =
200 to "<html><TITLE>Error</TITLE></html>"
setFirefoxFail()
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertTrue(result.whitelistDetected)
assertFalse(result.appleReachable)
}
@Test
fun `firefox body not starting with success is not reachable`() {
setGoogleFail()
setAppleFail()
responses["https://detectportal.firefox.com/success.txt"] = 200 to "fail"
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertTrue(result.whitelistDetected)
assertFalse(result.firefoxReachable)
}
@Test
fun `ru control accepts 3xx redirect as success`() {
responses["https://yandex.ru/"] = 301 to ""
setGoogleFail()
setAppleFail()
setFirefoxFail()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertTrue(result.russianControlReachable)
assertTrue(result.whitelistDetected)
}
@Test
fun `duration is non-negative`() {
setGoogleOk()
setAppleOk()
setFirefoxOk()
setRuOk()
val result = runBlocking { OperatorWhitelistProbe.probe() }
assertTrue(result.durationMs >= 0)
}
}

View file

@ -372,6 +372,91 @@ class UnderlyingNetworkProberTest {
)
}
@Test
fun `vpnNetwork null with tun0 present activates OsDeviceBinding fallback`() = runBlocking {
// No Android VPN network (app excluded from per-app VPN), but tun0 is visible.
var osDeviceFetcherCalled = false
UnderlyingNetworkProber.dependenciesOverride = UnderlyingNetworkProber.Dependencies(
initNativeCurl = {},
environmentProvider = {
// cm.allNetworks returns empty (no VPN network visible to excluded app)
UnderlyingNetworkProber.ProbeEnvironment(
activeNetwork = null,
networks = emptyList(),
)
},
comparisonFetcher = { _, _, _, _, _ ->
failureComparison("should not be called")
},
osDeviceComparisonFetcher = { interfaceName, _, _, _, targetUrls ->
osDeviceFetcherCalled = true
when {
interfaceName == "tun0" && targetUrls?.contains("https://ipv4-internet.yandex.net/api/v0/ip") == true ->
successfulComparison("203.0.113.50")
interfaceName == "tun0" ->
successfulComparison("203.0.113.51")
else ->
failureComparison("unexpected interface: $interfaceName")
}
},
)
val result = UnderlyingNetworkProber.probe(
context = context,
resolverConfig = DnsResolverConfig.system(),
tunInterfacePresent = true,
tunInterfaceName = "tun0",
underlyingInterfaceName = null,
)
assertTrue("osDeviceComparisonFetcher must be called", osDeviceFetcherCalled)
assertTrue(result.vpnActive)
assertFalse(result.underlyingReachable)
assertNotNull(result.ruTarget.vpnIp)
}
@Test
fun `vpnNetwork null tun0 and underlying differ produce dnsPathMismatch true`() = runBlocking {
UnderlyingNetworkProber.dependenciesOverride = UnderlyingNetworkProber.Dependencies(
initNativeCurl = {},
environmentProvider = {
UnderlyingNetworkProber.ProbeEnvironment(
activeNetwork = null,
networks = emptyList(),
)
},
comparisonFetcher = { _, _, _, _, _ ->
failureComparison("should not be called")
},
osDeviceComparisonFetcher = { interfaceName, _, _, _, targetUrls ->
val isRu = targetUrls?.contains("https://ipv4-internet.yandex.net/api/v0/ip") == true
when (interfaceName) {
// tun0 returns VPN exit IP
"tun0" -> if (isRu) successfulComparison("10.8.0.1") else successfulComparison("10.8.0.2")
// rmnet0 returns real carrier IP (different → mismatch)
"rmnet0" -> if (isRu) successfulComparison("203.0.113.1") else successfulComparison("203.0.113.2")
else -> failureComparison("unexpected: $interfaceName")
}
},
)
val result = UnderlyingNetworkProber.probe(
context = context,
resolverConfig = DnsResolverConfig.system(),
tunInterfacePresent = true,
tunInterfaceName = "tun0",
underlyingInterfaceName = "rmnet0",
)
assertTrue(result.vpnActive)
assertTrue(result.underlyingReachable)
assertTrue("dnsPathMismatch must be true when tun and underlying IPs differ", result.dnsPathMismatch)
// VPN IP comes from tun0, underlying from rmnet0
assertNotNull(result.ruTarget.vpnIp)
assertNotNull(result.ruTarget.directIp)
assertTrue(result.ruTarget.vpnIp != result.ruTarget.directIp)
}
private fun newNetwork(netId: Int): Network {
val constructor = Network::class.java.getDeclaredConstructor(Int::class.javaPrimitiveType)
constructor.isAccessible = true

View file

@ -0,0 +1,4 @@
Added operator whitelist-mode detection: a warning banner appears under the verdict to indicate the result may be distorted (#61).
Per-app split-tunnel VPN: added an OS-binding probe to tun0 — underlying network leak is now detected even when the app is excluded from the tunnel (#61).
DNS: automatic fallback to Yandex DoH (77.88.8.8) after three consecutive system-resolver failures — keeps probes working under operator whitelist mode.
JSON/Markdown/Debug exports extended with the operator_whitelist section.

View file

@ -0,0 +1,4 @@
افزوده شد: تشخیص حالت «فهرست سفید» اپراتور؛ زیر حکم، یک هشدار درباره احتمال نادرست بودن نتیجه نمایش داده می‌شود (#61).
VPN در حالت per-app split-tunnel: زوند جدید با اتصال OS-binding به tun0 افزوده شد؛ نشت شبکه زیرین حتی زمانی که برنامه از تونل کنار گذاشته شده باشد تشخیص داده می‌شود (#61).
DNS: پس از سه شکست متوالی resolver سیستمی، fallback خودکار به Yandex DoH (77.88.8.8) فعال می‌شود تا بررسی‌ها در شرایط فهرست سفید اپراتور کار کنند.
خروجی JSON/Markdown/Debug با بخش operator_whitelist گسترش یافت.

View file

@ -0,0 +1,4 @@
Добавлено определение режима «белых списков» оператора: под вердиктом появляется предупреждение, что результат может быть искажён (#61).
Per-app split-tunnel VPN: добавлен зонд через OS-binding к tun0, теперь утечка underlying-сети детектируется даже когда приложение исключено из туннеля (#61).
DNS: автоматический фолбэк на Yandex DoH (77.88.8.8) после трёх подряд провалов системного резолвера — спасает проверки под whitelist оператора.
JSON/Markdown/Debug-экспорт расширены секцией operator_whitelist.

View file

@ -0,0 +1,4 @@
新增运营商「白名单」模式检测:裁决下方会显示提示,说明结果可能受到干扰(#61
针对 per-app 分流 VPN新增通过 OS-binding 直接探测 tun0 的方式,即使本应用被排除在隧道之外,也能检测到底层网络泄漏(#61
DNS当系统解析器连续三次失败时自动切换到 Yandex DoH77.88.8.8),保证在运营商白名单模式下检查仍可工作。
Markdown/JSON/Debug 导出新增 operator_whitelist 分节。