mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-09 17:19:25 +00:00
feat: детекция белых списков оператора и per-app TUN-зонд (#61)
Some checks failed
CI / build (push) Has been cancelled
Some checks failed
CI / build (push) Has been cancelled
This commit is contained in:
parent
7b101d20e8
commit
63e9b97a36
35 changed files with 2767 additions and 1140 deletions
17
.gitattributes
vendored
Normal file
17
.gitattributes
vendored
Normal 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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("## Белые списки оператора"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
4
fastlane/metadata/android/en-US/changelogs/20702.txt
Normal file
4
fastlane/metadata/android/en-US/changelogs/20702.txt
Normal 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.
|
||||
4
fastlane/metadata/android/fa/changelogs/20702.txt
Normal file
4
fastlane/metadata/android/fa/changelogs/20702.txt
Normal 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 گسترش یافت.
|
||||
4
fastlane/metadata/android/ru-RU/changelogs/20702.txt
Normal file
4
fastlane/metadata/android/ru-RU/changelogs/20702.txt
Normal 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.
|
||||
4
fastlane/metadata/android/zh-CN/changelogs/20702.txt
Normal file
4
fastlane/metadata/android/zh-CN/changelogs/20702.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
新增运营商「白名单」模式检测:裁决下方会显示提示,说明结果可能受到干扰(#61)。
|
||||
针对 per-app 分流 VPN:新增通过 OS-binding 直接探测 tun0 的方式,即使本应用被排除在隧道之外,也能检测到底层网络泄漏(#61)。
|
||||
DNS:当系统解析器连续三次失败时自动切换到 Yandex DoH(77.88.8.8),保证在运营商白名单模式下检查仍可工作。
|
||||
Markdown/JSON/Debug 导出新增 operator_whitelist 分节。
|
||||
Loading…
Add table
Add a link
Reference in a new issue