feat: проверка ICMP (β)

This commit is contained in:
xtclovver 2026-04-20 21:37:12 +03:00
parent 10173549cb
commit e6d16d6821
24 changed files with 1141 additions and 7 deletions

View file

@ -74,6 +74,7 @@ internal object CheckResultJsonExportFormatter {
put("cdnPulling", cdnPullingToJson(snapshot.result.cdnPulling, snapshot.privacyMode))
put("directSigns", categoryToJson(snapshot.result.directSigns, snapshot.privacyMode))
put("indirectSigns", categoryToJson(snapshot.result.indirectSigns, snapshot.privacyMode))
put("icmpSpoofing", categoryToJson(snapshot.result.icmpSpoofing, snapshot.privacyMode))
put("locationSignals", categoryToJson(snapshot.result.locationSignals, snapshot.privacyMode))
put("bypass", bypassToJson(context, snapshot.result.bypassResult, snapshot.privacyMode))
},

View file

@ -43,6 +43,7 @@ internal object CheckResultMarkdownExportFormatter {
appendCdnPullingSection(builder, context, result.cdnPulling, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_direct_signs), result.directSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_indirect_signs), result.indirectSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_icmp_spoofing), result.icmpSpoofing, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_location_signals), result.locationSignals, snapshot.privacyMode)
appendIpChannelsSection(builder, result.ipConsensus, snapshot.privacyMode)
appendBypassSection(builder, context, result.bypassResult, snapshot.privacyMode)
@ -134,6 +135,12 @@ internal object CheckResultMarkdownExportFormatter {
status = sectionStatusTag(result.indirectSigns.detected, result.indirectSigns.needsReview, result.indirectSigns.hasError),
summary = buildCategorySummary(result.indirectSigns, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_icmp_spoofing),
status = sectionStatusTag(result.icmpSpoofing.detected, result.icmpSpoofing.needsReview, result.icmpSpoofing.hasError),
summary = buildCategorySummary(result.icmpSpoofing, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_location_signals),

View file

@ -59,6 +59,7 @@ object DebugDiagnosticsFormatter {
appendCdnPulling(builder, result.cdnPulling)
appendCategory(builder, "directSigns", result.directSigns)
appendCategory(builder, "indirectSigns", result.indirectSigns)
appendCategory(builder, "icmpSpoofing", result.icmpSpoofing)
appendIndirectPerformance(builder, result.indirectSigns)
appendCategory(builder, "locationSignals", result.locationSignals)
appendBypass(builder, result.bypassResult)

View file

@ -200,6 +200,7 @@ class MainActivity : AppCompatActivity() {
CDN_PULLING,
DIRECT,
INDIRECT,
ICMP,
LOCATION,
IP_CONSENSUS,
BYPASS,
@ -225,6 +226,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var textCallTransportSummary: TextView
private lateinit var stunGroupsContainer: LinearLayout
private lateinit var findingsCallTransport: LinearLayout
private lateinit var cardIcmpSpoofing: MaterialCardView
private lateinit var iconIcmpSpoofing: ImageView
private lateinit var statusIcmpSpoofing: TextView
private lateinit var findingsIcmpSpoofing: LinearLayout
private lateinit var cardNativeSigns: MaterialCardView
private lateinit var iconNativeSigns: ImageView
private lateinit var statusNativeSigns: TextView
@ -417,6 +422,10 @@ class MainActivity : AppCompatActivity() {
textCallTransportSummary = findViewById(R.id.textCallTransportSummary)
stunGroupsContainer = findViewById(R.id.stunGroupsContainer)
findingsCallTransport = findViewById(R.id.findingsCallTransport)
cardIcmpSpoofing = findViewById(R.id.cardIcmpSpoofing)
iconIcmpSpoofing = findViewById(R.id.iconIcmpSpoofing)
statusIcmpSpoofing = findViewById(R.id.statusIcmpSpoofing)
findingsIcmpSpoofing = findViewById(R.id.findingsIcmpSpoofing)
cardNativeSigns = findViewById(R.id.cardNativeSigns)
iconNativeSigns = findViewById(R.id.iconNativeSigns)
statusNativeSigns = findViewById(R.id.statusNativeSigns)
@ -495,6 +504,7 @@ class MainActivity : AppCompatActivity() {
Triple(CATEGORY_IND, getString(R.string.main_card_indirect_signs), R.drawable.ic_lan),
Triple(CATEGORY_NAT, getString(R.string.main_card_native_signs), R.drawable.ic_lock),
Triple(CATEGORY_STN, getString(R.string.main_card_call_transport), R.drawable.ic_call),
Triple(CATEGORY_ICM, getString(R.string.main_card_icmp_spoofing), R.drawable.ic_network),
Triple(CATEGORY_LOC, getString(R.string.main_card_location_signals), R.drawable.ic_location_on),
Triple(CATEGORY_BYP, getString(R.string.settings_split_tunnel), R.drawable.ic_call_split),
)
@ -532,6 +542,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.cardIndirect
CATEGORY_NAT -> R.id.cardNativeSigns
CATEGORY_STN -> R.id.cardCallTransport
CATEGORY_ICM -> R.id.cardIcmpSpoofing
CATEGORY_LOC -> R.id.cardLocation
CATEGORY_BYP -> R.id.cardBypass
else -> error("Unknown category id: $id")
@ -545,6 +556,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.headerIndirect
CATEGORY_NAT -> R.id.headerNativeSigns
CATEGORY_STN -> R.id.headerCallTransport
CATEGORY_ICM -> R.id.headerIcmpSpoofing
CATEGORY_LOC -> R.id.headerLocation
CATEGORY_BYP -> R.id.headerBypass
else -> error("Unknown category id: $id")
@ -558,6 +570,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.headerDotIndirect
CATEGORY_NAT -> R.id.headerDotNativeSigns
CATEGORY_STN -> R.id.headerDotCallTransport
CATEGORY_ICM -> R.id.headerDotIcmpSpoofing
CATEGORY_LOC -> R.id.headerDotLocation
CATEGORY_BYP -> R.id.headerDotBypass
else -> error("Unknown category id: $id")
@ -571,6 +584,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.headerIconIndirect
CATEGORY_NAT -> R.id.headerIconNativeSigns
CATEGORY_STN -> R.id.headerIconCallTransport
CATEGORY_ICM -> R.id.headerIconIcmpSpoofing
CATEGORY_LOC -> R.id.headerIconLocation
CATEGORY_BYP -> R.id.headerIconBypass
else -> error("Unknown category id: $id")
@ -584,6 +598,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.headerTitleIndirect
CATEGORY_NAT -> R.id.headerTitleNativeSigns
CATEGORY_STN -> R.id.headerTitleCallTransport
CATEGORY_ICM -> R.id.headerTitleIcmpSpoofing
CATEGORY_LOC -> R.id.headerTitleLocation
CATEGORY_BYP -> R.id.headerTitleBypass
else -> error("Unknown category id: $id")
@ -597,6 +612,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.headerHintIndirect
CATEGORY_NAT -> R.id.headerHintNativeSigns
CATEGORY_STN -> R.id.headerHintCallTransport
CATEGORY_ICM -> R.id.headerHintIcmpSpoofing
CATEGORY_LOC -> R.id.headerHintLocation
CATEGORY_BYP -> R.id.headerHintBypass
else -> error("Unknown category id: $id")
@ -610,6 +626,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.chevronIndirect
CATEGORY_NAT -> R.id.chevronNativeSigns
CATEGORY_STN -> R.id.chevronCallTransport
CATEGORY_ICM -> R.id.chevronIcmpSpoofing
CATEGORY_LOC -> R.id.chevronLocation
CATEGORY_BYP -> R.id.chevronBypass
else -> error("Unknown category id: $id")
@ -623,6 +640,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_IND -> R.id.bodyIndirect
CATEGORY_NAT -> R.id.bodyNativeSigns
CATEGORY_STN -> R.id.bodyCallTransport
CATEGORY_ICM -> R.id.bodyIcmpSpoofing
CATEGORY_LOC -> R.id.bodyLocation
CATEGORY_BYP -> R.id.bodyBypass
else -> error("Unknown category id: $id")
@ -1173,6 +1191,9 @@ class MainActivity : AppCompatActivity() {
findingsCallTransport.removeAllViews()
findingsCallTransport.visibility = View.GONE
findingsIcmpSpoofing.removeAllViews()
findingsIcmpSpoofing.visibility = View.GONE
textNativeSignsSummary.text = ""
textNativeSignsSummary.visibility = View.GONE
findingsNativeSigns.removeAllViews()
@ -1195,6 +1216,9 @@ class MainActivity : AppCompatActivity() {
if (settings.cdnPullingEnabled) {
stages += RunningStage.CDN_PULLING
}
if (settings.icmpSpoofingEnabled) {
stages += RunningStage.ICMP
}
}
stages += RunningStage.DIRECT
stages += RunningStage.INDIRECT
@ -1238,6 +1262,20 @@ class MainActivity : AppCompatActivity() {
updateTileFromCdn(update.result)
if (animate) animateContentReveal(textCdnPullingSummary, cdnPullingResponses)
}
is CheckUpdate.IcmpSpoofingReady -> {
markStageCompleted(RunningStage.ICMP)
ensureCardVisible(cardIcmpSpoofing, animate = false)
displayCategory(
update.result,
cardIcmpSpoofing,
iconIcmpSpoofing,
statusIcmpSpoofing,
findingsIcmpSpoofing,
activeCheckPrivacyMode,
)
updateTileFromCategory(CATEGORY_ICM, update.result)
if (animate) animateContentReveal(findingsIcmpSpoofing)
}
is CheckUpdate.DirectSignsReady -> {
markStageCompleted(RunningStage.DIRECT)
ensureCardVisible(cardDirect, animate = false)
@ -1320,6 +1358,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.CDN_PULLING -> CATEGORY_CDN
RunningStage.DIRECT -> CATEGORY_DIR
RunningStage.INDIRECT -> CATEGORY_IND
RunningStage.ICMP -> CATEGORY_ICM
RunningStage.LOCATION -> CATEGORY_LOC
RunningStage.IP_CONSENSUS -> CATEGORY_IPS
RunningStage.BYPASS -> CATEGORY_BYP
@ -1345,6 +1384,14 @@ class MainActivity : AppCompatActivity() {
)
RunningStage.IP_COMPARISON -> showIpComparisonLoading(stage)
RunningStage.CDN_PULLING -> showCdnPullingLoading(stage)
RunningStage.ICMP -> showCategoryLoading(
stage = stage,
card = cardIcmpSpoofing,
icon = iconIcmpSpoofing,
status = statusIcmpSpoofing,
findingsContainer = findingsIcmpSpoofing,
hint = stageLoadingMessage(stage),
)
RunningStage.DIRECT -> showCategoryLoading(
stage = stage,
card = cardDirect,
@ -1452,6 +1499,13 @@ class MainActivity : AppCompatActivity() {
)
RunningStage.IP_COMPARISON -> showIpComparisonStopped(stage)
RunningStage.CDN_PULLING -> showCdnPullingStopped(stage)
RunningStage.ICMP -> showCategoryStopped(
card = cardIcmpSpoofing,
icon = iconIcmpSpoofing,
status = statusIcmpSpoofing,
findingsContainer = findingsIcmpSpoofing,
message = stageStoppedMessage(stage),
)
RunningStage.DIRECT -> showCategoryStopped(
card = cardDirect,
icon = iconDirect,
@ -1596,6 +1650,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.GEO_IP -> getString(R.string.main_loading_geo_ip)
RunningStage.IP_COMPARISON -> getString(R.string.main_loading_ip_comparison)
RunningStage.CDN_PULLING -> getString(R.string.main_loading_cdn_pulling)
RunningStage.ICMP -> getString(R.string.main_loading_icmp)
RunningStage.DIRECT -> getString(R.string.main_loading_direct)
RunningStage.INDIRECT -> getString(R.string.main_loading_indirect)
RunningStage.LOCATION -> getString(R.string.main_loading_location)
@ -1616,6 +1671,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.GEO_IP -> cardGeoIp
RunningStage.IP_COMPARISON -> cardIpComparison
RunningStage.CDN_PULLING -> cardCdnPulling
RunningStage.ICMP -> cardIcmpSpoofing
RunningStage.DIRECT -> cardDirect
RunningStage.INDIRECT -> cardIndirect
RunningStage.LOCATION -> cardLocation
@ -1629,6 +1685,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.GEO_IP -> statusGeoIp
RunningStage.IP_COMPARISON -> statusIpComparison
RunningStage.CDN_PULLING -> statusCdnPulling
RunningStage.ICMP -> statusIcmpSpoofing
RunningStage.DIRECT -> statusDirect
RunningStage.INDIRECT -> statusIndirect
RunningStage.LOCATION -> statusLocation
@ -2884,6 +2941,7 @@ class MainActivity : AppCompatActivity() {
private const val CATEGORY_DIR = "dir"
private const val CATEGORY_IND = "ind"
private const val CATEGORY_STN = "stn"
private const val CATEGORY_ICM = "icmp"
private const val CATEGORY_LOC = "loc"
private const val CATEGORY_BYP = "byp"
private const val CATEGORY_NAT = "nat"

View file

@ -260,6 +260,9 @@ object VerdictNarrativeBuilder {
if (result.indirectSigns.callTransportLeaks.any { it.status == CallTransportStatus.NEEDS_REVIEW }) {
reasons += context.getString(R.string.narrative_reason_call_transport_signal)
}
if (result.icmpSpoofing.needsReview) {
reasons += context.getString(R.string.narrative_reason_icmp_signal)
}
if (reasons.isEmpty()) {
reasons += when (result.verdict) {
@ -290,6 +293,8 @@ object VerdictNarrativeBuilder {
result.locationSignals.needsReview ||
result.ipComparison.detected ||
result.ipComparison.needsReview ||
result.icmpSpoofing.detected ||
result.icmpSpoofing.needsReview ||
result.directSigns.findings.any { it.source == EvidenceSource.TUN_ACTIVE_PROBE } ||
result.indirectSigns.callTransportLeaks.any { it.status == CallTransportStatus.NEEDS_REVIEW } ||
result.bypassResult.findings.any {

View file

@ -0,0 +1,243 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import com.notcvnt.rknhardering.probe.SystemPingProber
import java.io.IOException
import java.net.Inet4Address
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object IcmpSpoofingChecker {
private const val RTT_THRESHOLD_MS = 10.0
internal data class Target(
val host: String,
val role: Role,
)
internal enum class Role {
BLOCKED,
CONTROL,
}
internal data class TargetOutcome(
val target: Target,
val address: String,
val ping: SystemPingProber.PingResult,
)
internal data class Dependencies(
val resolveIpv4: (String, DnsResolverConfig) -> String = { host, resolverConfig ->
val addresses = ResolverNetworkStack.lookup(
hostname = host,
config = resolverConfig,
cancellationSignal = ScanExecutionContext.currentOrDefault().cancellationSignal,
)
val ipv4 = addresses.firstOrNull { it is Inet4Address }
?: throw IOException("No IPv4 address resolved for $host")
ipv4.hostAddress ?: throw IOException("Resolved IPv4 address is empty for $host")
},
val ping: suspend (String) -> SystemPingProber.PingResult = { address ->
SystemPingProber.probe(address = address)
},
)
@Volatile
internal var dependenciesOverride: Dependencies? = null
private val defaultTargets = listOf(
Target(host = "instagram.com", role = Role.BLOCKED),
Target(host = "google.com", role = Role.CONTROL),
)
suspend fun check(
context: Context,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
): CategoryResult = withContext(Dispatchers.IO) {
val dependencies = dependenciesOverride ?: Dependencies()
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
val outcomes = mutableListOf<TargetOutcome>()
try {
for (target in defaultTargets) {
val address = dependencies.resolveIpv4(target.host, resolverConfig)
val ping = dependencies.ping(address)
outcomes += TargetOutcome(target = target, address = address, ping = ping)
}
} catch (error: Throwable) {
return@withContext unsupportedResult(context, error)
}
val blockedOutcome = outcomes.first { it.target.role == Role.BLOCKED }
val controlOutcome = outcomes.first { it.target.role == Role.CONTROL }
val blockedUnexpectedReply = blockedOutcome.ping.hasReplies
val controlRttTooLow =
controlOutcome.ping.hasReplies &&
(
(controlOutcome.ping.minRttMs != null && controlOutcome.ping.minRttMs < RTT_THRESHOLD_MS) ||
(controlOutcome.ping.avgRttMs != null && controlOutcome.ping.avgRttMs < RTT_THRESHOLD_MS)
)
val controlNoReply = !controlOutcome.ping.hasReplies
when {
controlNoReply -> {
findings += Finding(
description = context.getString(
R.string.checker_icmp_summary_inconclusive,
controlOutcome.target.host,
),
isInformational = true,
)
findings += Finding(
description = "ICMP control target ${controlOutcome.target.host} did not reply",
isError = true,
source = EvidenceSource.ICMP_SPOOFING,
)
}
blockedUnexpectedReply && controlRttTooLow -> {
findings += Finding(
description = context.getString(
R.string.checker_icmp_summary_both_suspicious,
blockedOutcome.target.host,
controlOutcome.target.host,
formatRtt(controlOutcome.ping.minRttMs),
formatRtt(controlOutcome.ping.avgRttMs),
),
needsReview = true,
source = EvidenceSource.ICMP_SPOOFING,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += suspiciousEvidence(
context.getString(
R.string.checker_icmp_evidence_both,
blockedOutcome.target.host,
controlOutcome.target.host,
),
)
}
blockedUnexpectedReply -> {
findings += Finding(
description = context.getString(
R.string.checker_icmp_summary_blocked_replied,
blockedOutcome.target.host,
),
needsReview = true,
source = EvidenceSource.ICMP_SPOOFING,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += suspiciousEvidence(
context.getString(
R.string.checker_icmp_evidence_blocked_replied,
blockedOutcome.target.host,
),
)
}
controlRttTooLow -> {
findings += Finding(
description = context.getString(
R.string.checker_icmp_summary_control_too_fast,
controlOutcome.target.host,
formatRtt(controlOutcome.ping.minRttMs),
formatRtt(controlOutcome.ping.avgRttMs),
),
needsReview = true,
source = EvidenceSource.ICMP_SPOOFING,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += suspiciousEvidence(
context.getString(
R.string.checker_icmp_evidence_control_too_fast,
controlOutcome.target.host,
),
)
}
else -> {
findings += Finding(
description = context.getString(
R.string.checker_icmp_summary_clean,
blockedOutcome.target.host,
controlOutcome.target.host,
formatRtt(controlOutcome.ping.avgRttMs),
),
isInformational = true,
)
}
}
findings += outcomes.map { outcome ->
Finding(
description = when (outcome.target.role) {
Role.BLOCKED -> context.getString(
R.string.checker_icmp_target_blocked,
outcome.target.host,
outcome.address,
outcome.ping.received,
outcome.ping.sent,
)
Role.CONTROL -> context.getString(
R.string.checker_icmp_target_control,
outcome.target.host,
outcome.address,
outcome.ping.received,
outcome.ping.sent,
formatRtt(outcome.ping.minRttMs),
formatRtt(outcome.ping.avgRttMs),
formatRtt(outcome.ping.maxRttMs),
)
},
isInformational = true,
)
}
CategoryResult(
name = context.getString(R.string.main_card_icmp_spoofing),
detected = false,
findings = findings,
needsReview = findings.any { it.needsReview },
evidence = evidence,
)
}
private fun unsupportedResult(context: Context, error: Throwable): CategoryResult {
return CategoryResult(
name = context.getString(R.string.main_card_icmp_spoofing),
detected = false,
findings = listOf(
Finding(
description = context.getString(R.string.checker_icmp_summary_unavailable),
isInformational = true,
),
Finding(
description = error.message ?: error::class.java.simpleName,
isError = true,
source = EvidenceSource.ICMP_SPOOFING,
),
),
)
}
private fun suspiciousEvidence(description: String): EvidenceItem {
return EvidenceItem(
source = EvidenceSource.ICMP_SPOOFING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = description,
)
}
private fun formatRtt(value: Double?): String {
return value?.let { String.format(Locale.US, "%.1f ms", it) } ?: "n/a"
}
}

View file

@ -47,6 +47,7 @@ object VerdictEngine {
bypassResult: BypassResult,
ipConsensus: IpConsensusResult,
nativeSigns: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
icmpSpoofing: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
): Verdict {
// R1
if (bypassResult.evidence.any { it.detected && it.source in HARD_DETECT_BYPASS }) {
@ -118,6 +119,7 @@ object VerdictEngine {
if (matrix == Verdict.NOT_DETECTED && (
bypassResult.needsReview ||
hasActionableCallTransportLeak ||
icmpSpoofing.needsReview ||
nativeReviewHit ||
ipConsensus.needsReview ||
ipConsensus.channelConflict.isNotEmpty() ||

View file

@ -9,6 +9,7 @@ import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CheckResult
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
@ -35,6 +36,7 @@ data class CheckSettings(
val callTransportProbeEnabled: Boolean = false,
val cdnPullingEnabled: Boolean = false,
val cdnPullingMeduzaEnabled: Boolean = true,
val icmpSpoofingEnabled: Boolean = true,
val tunProbeDebugEnabled: Boolean = false,
val tunProbeModeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
val resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
@ -47,6 +49,7 @@ sealed interface CheckUpdate {
data class GeoIpReady(val result: CategoryResult) : CheckUpdate
data class IpComparisonReady(val result: IpComparisonResult) : CheckUpdate
data class CdnPullingReady(val result: CdnPullingResult) : CheckUpdate
data class IcmpSpoofingReady(val result: CategoryResult) : CheckUpdate
data class DirectSignsReady(val result: CategoryResult) : CheckUpdate
data class IndirectSignsReady(val result: CategoryResult) : CheckUpdate
data class LocationSignalsReady(val result: CategoryResult) : CheckUpdate
@ -66,6 +69,8 @@ object VpnCheckRunner {
{ ctx, resolverConfig -> IpComparisonChecker.check(ctx, resolverConfig = resolverConfig) },
val cdnPullingCheck: suspend (Context, DnsResolverConfig, Boolean) -> CdnPullingResult =
{ ctx, resolverConfig, meduzaEnabled -> CdnPullingChecker.check(ctx, resolverConfig = resolverConfig, meduzaEnabled = meduzaEnabled) },
val icmpSpoofingCheck: suspend (Context, DnsResolverConfig) -> CategoryResult =
{ ctx, resolverConfig -> IcmpSpoofingChecker.check(ctx, resolverConfig) },
val underlyingProbe: suspend (
Context,
DnsResolverConfig,
@ -171,6 +176,21 @@ object VpnCheckRunner {
vpnActive = false,
vpnError = error.message,
)
fun icmp(context: Context, error: Throwable): CategoryResult = CategoryResult(
name = context.getString(R.string.main_card_icmp_spoofing),
detected = false,
findings = listOf(
Finding(
context.getString(R.string.checker_icmp_summary_unavailable),
isInformational = true,
),
Finding(
error.message ?: error::class.java.simpleName,
isError = true,
source = EvidenceSource.ICMP_SPOOFING,
),
),
)
fun direct(context: Context, error: Throwable): CategoryResult = CategoryResult(
name = context.getString(R.string.checker_direct_category_name),
detected = false,
@ -231,6 +251,12 @@ object VpnCheckRunner {
}
} else null
val icmpSpoofingDeferred = if (settings.networkRequestsEnabled && settings.icmpSpoofingEnabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.icmp(context, it) }) {
dependencies.icmpSpoofingCheck(context, settings.resolverConfig)
}
} else null
val tunActiveProbeDeferred = if (settings.splitTunnelEnabled) {
safeAsync(fallback = { Fallbacks.probe(it) }) {
dependencies.underlyingProbe(
@ -307,6 +333,14 @@ object VpnCheckRunner {
}
}
}
val icmpSpoofingReadyDeferred = icmpSpoofingDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.IcmpSpoofingReady(result))
}
}
}
val directReadyDeferred = async {
directDeferred.await().also { result ->
executionContext.throwIfCancelled()
@ -360,6 +394,11 @@ object VpnCheckRunner {
),
)
val emptyCdnPulling = CdnPullingResult.empty()
val emptyIcmpSpoofing = CategoryResult(
name = context.getString(R.string.main_card_icmp_spoofing),
detected = false,
findings = emptyList(),
)
val emptyBypass = BypassResult(
proxyEndpoint = null,
proxyOwner = null,
@ -375,6 +414,7 @@ object VpnCheckRunner {
val geoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory
val ipComparison = ipComparisonReadyDeferred?.await() ?: emptyIpComparison
val cdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling
val icmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing
val directSigns = directReadyDeferred.await()
val indirectSigns = indirectReadyDeferred.await()
val locationSignals = locationReadyDeferred.await()
@ -406,6 +446,7 @@ object VpnCheckRunner {
bypassResult = bypassResult,
ipConsensus = ipConsensus,
nativeSigns = nativeSigns,
icmpSpoofing = icmpSpoofing,
)
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.VerdictReady(verdict))
@ -421,8 +462,9 @@ object VpnCheckRunner {
verdict = verdict,
tunProbeDiagnostics = tunProbeResult?.tunProbeDiagnostics,
nativeSigns = nativeSigns,
icmpSpoofing = icmpSpoofing,
ipConsensus = ipConsensus,
)
}
}
}
}

View file

@ -24,6 +24,7 @@ enum class EvidenceSource {
GEO_IP,
DIRECT_NETWORK_CAPABILITIES,
INDIRECT_NETWORK_CAPABILITIES,
ICMP_SPOOFING,
SYSTEM_PROXY,
INSTALLED_APP,
VPN_SERVICE_DECLARATION,
@ -327,5 +328,10 @@ data class CheckResult(
detected = false,
findings = emptyList(),
),
val icmpSpoofing: CategoryResult = CategoryResult(
name = "",
detected = false,
findings = emptyList(),
),
val ipConsensus: IpConsensusResult = IpConsensusResult.empty(),
)

View file

@ -0,0 +1,197 @@
package com.notcvnt.rknhardering.probe
import com.notcvnt.rknhardering.ScanExecutionContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.math.roundToInt
object SystemPingProber {
private const val DEFAULT_COUNT = 3
private const val DEFAULT_REPLY_TIMEOUT_SECONDS = 4
private const val OUTPUT_PREVIEW_LIMIT = 400
private val SUMMARY_REGEX = Regex("""(\d+)\s+packets transmitted,\s*(\d+)\s+(?:packets\s+)?received""")
private val RTT_REGEX = Regex(
"""(?:round-trip|rtt)\s+min/avg/max(?:/[a-z]+)?\s*=\s*([0-9.]+)/([0-9.]+)/([0-9.]+)""",
RegexOption.IGNORE_CASE,
)
private val REPLY_TIME_REGEX = Regex("""time[=<]([0-9.]+)\s*ms""", RegexOption.IGNORE_CASE)
internal data class CommandResult(
val exitCode: Int,
val output: String,
)
data class PingResult(
val address: String,
val sent: Int,
val received: Int,
val minRttMs: Double? = null,
val avgRttMs: Double? = null,
val maxRttMs: Double? = null,
val exitCode: Int,
val rawOutput: String,
) {
val hasReplies: Boolean
get() = received > 0
fun compactSummary(): String {
val stats = if (minRttMs != null && avgRttMs != null && maxRttMs != null) {
"min ${formatMs(minRttMs)}, avg ${formatMs(avgRttMs)}, max ${formatMs(maxRttMs)}"
} else {
"RTT unavailable"
}
return "$received/$sent replies, $stats"
}
}
@Volatile
internal var runCommandOverride: ((List<String>) -> CommandResult)? = null
suspend fun probe(
address: String,
count: Int = DEFAULT_COUNT,
replyTimeoutSeconds: Int = DEFAULT_REPLY_TIMEOUT_SECONDS,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): PingResult = withContext(Dispatchers.IO) {
executionContext.throwIfCancelled()
val commands = listOf(
buildCommand(binary = "ping", forceIpv4 = true, count = count, replyTimeoutSeconds = replyTimeoutSeconds, address = address),
buildCommand(binary = "ping", forceIpv4 = false, count = count, replyTimeoutSeconds = replyTimeoutSeconds, address = address),
buildCommand(binary = "/system/bin/ping", forceIpv4 = true, count = count, replyTimeoutSeconds = replyTimeoutSeconds, address = address),
buildCommand(binary = "/system/bin/ping", forceIpv4 = false, count = count, replyTimeoutSeconds = replyTimeoutSeconds, address = address),
)
var lastError: Throwable? = null
for (command in commands) {
executionContext.throwIfCancelled()
try {
val commandResult = runCommand(command, executionContext)
return@withContext try {
parse(address, commandResult)
} catch (error: IOException) {
throw IOException(
buildParseFailureMessage(
command = command,
commandResult = commandResult,
),
error,
)
}
} catch (error: Throwable) {
lastError = error
}
}
throw IOException(lastError?.message ?: "System ping is unavailable", lastError)
}
internal fun parse(
address: String,
commandResult: CommandResult,
): PingResult {
val summaryMatch = SUMMARY_REGEX.find(commandResult.output)
val sent = summaryMatch?.groupValues?.getOrNull(1)?.toIntOrNull()
val received = summaryMatch?.groupValues?.getOrNull(2)?.toIntOrNull()
val rttMatch = RTT_REGEX.find(commandResult.output)
val minFromSummary = rttMatch?.groupValues?.getOrNull(1)?.toDoubleOrNull()
val avgFromSummary = rttMatch?.groupValues?.getOrNull(2)?.toDoubleOrNull()
val maxFromSummary = rttMatch?.groupValues?.getOrNull(3)?.toDoubleOrNull()
val replySamples = REPLY_TIME_REGEX.findAll(commandResult.output)
.mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() }
.toList()
val effectiveSent = sent ?: if (replySamples.isNotEmpty()) DEFAULT_COUNT else null
val effectiveReceived = received ?: replySamples.size.takeIf { it > 0 }
if (effectiveSent == null || effectiveReceived == null) {
throw IOException("Failed to parse ping output")
}
val minRtt = minFromSummary ?: replySamples.minOrNull()
val avgRtt = avgFromSummary ?: replySamples.takeIf { it.isNotEmpty() }?.average()
val maxRtt = maxFromSummary ?: replySamples.maxOrNull()
return PingResult(
address = address,
sent = effectiveSent,
received = effectiveReceived,
minRttMs = minRtt,
avgRttMs = avgRtt,
maxRttMs = maxRtt,
exitCode = commandResult.exitCode,
rawOutput = commandResult.output,
)
}
private fun runCommand(
command: List<String>,
executionContext: ScanExecutionContext,
): CommandResult {
runCommandOverride?.let { return it(command) }
val process = ProcessBuilder(command)
.redirectErrorStream(true)
.start()
val registration = executionContext.cancellationSignal.register {
runCatching { process.destroyForcibly() }
}
try {
val output = process.inputStream.bufferedReader().use { it.readText() }
executionContext.throwIfCancelled()
val exitCode = process.waitFor()
executionContext.throwIfCancelled()
return CommandResult(
exitCode = exitCode,
output = output,
)
} finally {
registration.dispose()
runCatching { process.destroy() }
}
}
private fun formatMs(value: Double): String {
val rounded = (value * 10).roundToInt() / 10.0
return if (rounded % 1.0 == 0.0) "${rounded.toInt()} ms" else "$rounded ms"
}
private fun buildCommand(
binary: String,
forceIpv4: Boolean,
count: Int,
replyTimeoutSeconds: Int,
address: String,
): List<String> {
return buildList {
add(binary)
if (forceIpv4) add("-4")
add("-n")
add("-c")
add(count.toString())
add("-W")
add(replyTimeoutSeconds.toString())
add(address)
}
}
private fun buildParseFailureMessage(
command: List<String>,
commandResult: CommandResult,
): String {
val commandName = command.firstOrNull() ?: "ping"
val outputPreview = commandResult.output
.replace("\r", "")
.replace("\n", "\\n")
.ifBlank { "<empty>" }
.let { preview ->
if (preview.length <= OUTPUT_PREVIEW_LIMIT) {
preview
} else {
preview.take(OUTPUT_PREVIEW_LIMIT) + "..."
}
}
return "Failed to parse ping output (command=$commandName, exitCode=${commandResult.exitCode}, output=$outputPreview)"
}
}

View file

@ -1071,6 +1071,139 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardIcmpSpoofing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
app:cardBackgroundColor="@android:color/transparent"
app:cardCornerRadius="0dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/headerIcmpSpoofing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_category_header_collapsed"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="48dp"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="11dp"
android:paddingEnd="14dp"
android:paddingBottom="11dp">
<View
android:id="@+id/headerDotIcmpSpoofing"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/dot_status_neutral" />
<ImageView
android:id="@+id/headerIconIcmpSpoofing"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_network"
app:tint="?android:attr/textColorSecondary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/headerTitleIcmpSpoofing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Подмена ICMP β" />
<TextView
android:id="@+id/headerHintIcmpSpoofing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp"
tools:text="ping-проверка активного маршрута" />
</LinearLayout>
<ImageView
android:id="@+id/chevronIcmpSpoofing"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_chevron_right"
app:tint="?android:attr/textColorSecondary" />
</LinearLayout>
<LinearLayout
android:id="@+id/bodyIcmpSpoofing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_category_body"
android:orientation="vertical"
android:paddingStart="14dp"
android:paddingTop="4dp"
android:paddingEnd="14dp"
android:paddingBottom="14dp"
android:visibility="gone">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<ImageView
android:id="@+id/iconIcmpSpoofing"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/statusIcmpSpoofing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/findingsIcmpSpoofing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardLocation"
android:layout_width="match_parent"

View file

@ -151,6 +151,7 @@
<string name="main_card_call_transport_stun_ipv6">IPv6</string>
<string name="main_card_call_transport_stun_no_response">بدون پاسخ</string>
<string name="main_card_call_transport_stun_error">خطا</string>
<string name="main_card_icmp_spoofing">جعل ICMP β</string>
<string name="main_card_location_signals">نشانه‌های مکان</string>
<string name="main_card_native_signs">نشانه‌های بومی</string>
<string name="main_verdict_details">جزئیات</string>
@ -177,6 +178,7 @@
<string name="main_loading_geo_ip">در حال بررسی کشور، ASN و نشانه‌های میزبانی.</string>
<string name="main_loading_ip_comparison">در حال مقایسه IP عمومی از طریق چند سرویس مستقل.</string>
<string name="main_loading_cdn_pulling">در حال بررسی اینکه endpointهای مسدود یا CDN-backed چه داده‌ای درباره شبکه شما برمی‌گردانند.</string>
<string name="main_loading_icmp">در حال resolve و ping کردن میزبان‌های مسدود و کنترلی از طریق مسیر فعال.</string>
<string name="main_loading_direct">در حال جست‌وجوی نشانه‌های مستقیم VPN، proxy و کلاینت‌های نصب‌شده.</string>
<string name="main_loading_indirect">در حال بررسی اینترفیس‌ها، DNS، مسیرها و نشانه‌های فنی محلی.</string>
<string name="main_loading_location">در حال جمع‌آوری نشانه‌های اپراتور، دکل و Wi-Fiهای نزدیک.</string>
@ -229,6 +231,19 @@
<string name="narrative_explanation_exposure_local_proxy">فقط یک proxy/API محلی روی دستگاه دیده شد، بدون آدرس نقطه پایانی راه‌دور.</string>
<string name="narrative_explanation_exposure_technical">فقط نشانه‌های فنی شبکه و مسیریابی موجود بود، بدون آدرس سرور.</string>
<string name="narrative_explanation_exposure_insufficient">نه آدرس سرور و نه IP عمومی به دست نیامد.</string>
<string name="narrative_reason_icmp_signal">پاسخ‌های ICMP یا زمان رفت‌وبرگشت غیرواقعی دیده شد و نیاز به بررسی دستی دارد.</string>
<string name="checker_icmp_summary_clean">%1$s پاسخ نداد و %2$s بالاتر از آستانه RTT باقی ماند (میانگین %3$s)</string>
<string name="checker_icmp_summary_blocked_replied">%1$s به ICMP پاسخ داد، در حالی که انتظار می‌رود روی مسیر روسیه بی‌پاسخ بماند</string>
<string name="checker_icmp_summary_control_too_fast">%1$s با RTT مشکوکاً پایین پاسخ داد (کمینه %2$s، میانگین %3$s)</string>
<string name="checker_icmp_summary_both_suspicious">%1$s پاسخ داد و %2$s بیش از حد سریع بود (کمینه %3$s، میانگین %4$s)</string>
<string name="checker_icmp_summary_inconclusive">بررسی ICMP قطعی نیست، چون %1$s پاسخ نداد</string>
<string name="checker_icmp_summary_unavailable">پینگ سیستمی در دسترس نبود یا خروجی آن قابل تفسیر نبود.</string>
<string name="checker_icmp_target_blocked">هدف مسدودشده %1$s (%2$s): %3$d/%4$d پاسخ</string>
<string name="checker_icmp_target_control">هدف کنترلی %1$s (%2$s): %3$d/%4$d پاسخ، کمینه %5$s، میانگین %6$s، بیشینه %7$s</string>
<string name="checker_icmp_evidence_both">%1$s پاسخ داد و RTT برای %2$s پایین‌تر از آستانه مورد انتظار ماند</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s روی مسیر فعال از طریق ICMP پاسخ داد</string>
<string name="checker_icmp_evidence_control_too_fast">RTT برای %1$s روی مسیر فعال پایین‌تر از آستانه مورد انتظار ماند</string>
<string name="narrative_meaning_detected">نتیجه قرمز یعنی بررسی خودکار نشانه‌های کافی از دور زدن را جمع کرده است.</string>
<string name="narrative_meaning_needs_review">نتیجه زرد یعنی نشانه‌هایی وجود دارد، اما نیاز به بررسی دستی دارد.</string>

View file

@ -1,7 +1,7 @@
<resources>
<string name="app_name">RKNHardering</string>
<string name="github_repo_url">https://github.com/xtclovver/RKNHardering</string>
<string name="run_check_notice">Во время проверки приложение выполнит сетевые запросы к доменам:\n• api.beacondb.net\n• api.ipapi.is\n• www.iplocate.io\n• ip.mail.ru\n• api4.ipify.org\n• api6.ipify.org\n• 2ip.ru\n• ipv4-internet.yandex.net\n• ipv6-internet.yandex.net\n• ifconfig.me\n• ipv4.ifconfig.me\n• ipv6.ifconfig.me\n• checkip.amazonaws.com\n• api.ipify.org\n• api-ipv4.ip.sb\n• api-ipv6.ip.sb\n\nЕсли в настройках выбран кастомный DNS-резолвер, приложение также будет обращаться к его IP-адресам или DoH-домену.\n\nЕсли включён CDN pulling, приложение дополнительно отправит HTTPS-запросы к redirector.googlevideo.com, rutracker.org и meduza.io, чтобы проверить, какой внешний IP и trace-данные эти точки вернут.\n\nЕсли включена проверка утечек call transport, приложение дополнительно отправит UDP/STUN-запросы к встроенным Telegram-целям. Цели WhatsApp используются только в debug или experimental сборках.\n\nЕсли включена отладочная диагностика, приложение сможет сформировать копируемый диагностический отчёт по последней завершённой проверке, даже если она прошла успешно. Отчёт хранится только в памяти, пока вы его не скопируете. Ответы CDN pulling тоже попадут в этот отчёт.\n\nНажимая кнопку «Запустить проверку», вы соглашаетесь, что с вашего устройства будут выполнены эти сетевые запросы.\n\nПриложение не ведет логи этих запросов. Доступ к сети можно будет отключить в настройках приложения.</string>
<string name="run_check_notice">Во время проверки приложение выполнит сетевые запросы к доменам:\n• api.beacondb.net\n• api.ipapi.is\n• www.iplocate.io\n• ip.mail.ru\n• api4.ipify.org\n• api6.ipify.org\n• 2ip.ru\n• ipv4-internet.yandex.net\n• ipv6-internet.yandex.net\n• ifconfig.me\n• ipv4.ifconfig.me\n• ipv6.ifconfig.me\n• checkip.amazonaws.com\n• api.ipify.org\n• api-ipv4.ip.sb\n• api-ipv6.ip.sb\n\nЕсли в настройках выбран кастомный DNS-резолвер, приложение также будет обращаться к его IP-адресам или DoH-домену.\n\nЕсли включён CDN pulling, приложение дополнительно отправит HTTPS-запросы к redirector.googlevideo.com, rutracker.org и meduza.io, чтобы проверить, какой внешний IP и trace-данные эти точки вернут.\n\nЕсли включена проверка утечек call transport, приложение дополнительно отправит UDP/STUN-запросы к встроенным Telegram-целям. Цели WhatsApp используются только в debug или experimental сборках.\n\nЕсли включена проверка подмены ICMP, приложение дополнительно выполнит DNS-резолв и ping для instagram.com и google.com, чтобы сравнить доступность заблокированной цели и подозрительно низкие RTT.\n\nЕсли включена отладочная диагностика, приложение сможет сформировать копируемый диагностический отчёт по последней завершённой проверке, даже если она прошла успешно. Отчёт хранится только в памяти, пока вы его не скопируете. Ответы CDN pulling тоже попадут в этот отчёт.\n\nНажимая кнопку «Запустить проверку», вы соглашаетесь, что с вашего устройства будут выполнены эти сетевые запросы.\n\nПриложение не ведет логи этих запросов. Доступ к сети можно будет отключить в настройках приложения.</string>
<string name="settings_title">Настройки</string>
<string name="settings_section_checks">Проверки</string>
@ -49,9 +49,9 @@
<string name="settings_port_full">Полный</string>
<string name="settings_port_custom">Кастомный</string>
<string name="settings_network_requests">Сетевые запросы</string>
<string name="settings_network_requests_desc">GeoIP, IP сравнение, CDN pulling, BeaconDB, проверка call transport</string>
<string name="settings_network_requests_desc">GeoIP, IP сравнение, CDN pulling, BeaconDB, проверка call transport, подмена ICMP</string>
<string name="settings_network_disable_title">Отключить сетевые запросы?</string>
<string name="settings_network_disable_message">При отключении:\n\n• GeoIP проверка не будет выполнена\n• Сравнение IP-адресов через внешние сервисы отключится\n• Проверка CDN pulling отключится\n• Геолокация через BeaconDB (вышки/Wi-Fi) отключится\n• Проверка утечек call transport отключится\n\nБудут работать только локальные проверки: прямые признаки, косвенные признаки, сканирование портов</string>
<string name="settings_network_disable_message">При отключении:\n\n• GeoIP проверка не будет выполнена\n• Сравнение IP-адресов через внешние сервисы отключится\n• Проверка CDN pulling отключится\n• Геолокация через BeaconDB (вышки/Wi-Fi) отключится\n• Проверка утечек call transport отключится\n• Проверка подмены ICMP отключится\n\nБудут работать только локальные проверки: прямые признаки, косвенные признаки, сканирование портов</string>
<string name="settings_network_disable_confirm">Отключить</string>
<string name="settings_cdn_pulling">CDN pulling</string>
<string name="settings_cdn_pulling_desc">Отправляет HTTPS-запросы к Google/YouTube redirector и Cloudflare trace endpoints.</string>
@ -151,6 +151,7 @@
<string name="main_card_call_transport_stun_ipv6">IPv6</string>
<string name="main_card_call_transport_stun_no_response">Нет ответа</string>
<string name="main_card_call_transport_stun_error">Ошибка</string>
<string name="main_card_icmp_spoofing">Подмена ICMP β</string>
<string name="main_card_location_signals">Сигналы местоположения</string>
<string name="main_card_native_signs">Нативные признаки</string>
<string name="main_verdict_details">Подробнее</string>
@ -177,6 +178,7 @@
<string name="main_loading_geo_ip">Сверяем страну, ASN и признаки хостинга.</string>
<string name="main_loading_ip_comparison">Сравниваем внешний IP через несколько независимых сервисов.</string>
<string name="main_loading_cdn_pulling">Проверяем, какие сетевые данные вернут заблокированные или CDN-backed endpoints.</string>
<string name="main_loading_icmp">Резолвим и пингуем заблокированную и контрольную цели через активный маршрут.</string>
<string name="main_loading_direct">Ищем прямые признаки VPN, прокси и установленных клиентов.</string>
<string name="main_loading_indirect">Проверяем интерфейсы, DNS, маршруты и локальные технические сигналы.</string>
<string name="main_loading_location">Собираем сигналы оператора, вышек и ближайших Wi-Fi точек.</string>
@ -282,6 +284,7 @@
<string name="narrative_reason_direct_signs">Найдены прямые признаки локального proxy/VPN.</string>
<string name="narrative_reason_indirect_signs">Найдены косвенные признаки туннеля, маршрутов или DNS-подмены.</string>
<string name="narrative_reason_call_transport_signal">Цель call transport ответила и раскрыла mapped/public network data.</string>
<string name="narrative_reason_icmp_signal">ICMP-доступность или RTT выглядели нетипично для обычного российского маршрута.</string>
<string name="narrative_reason_fallback_detected">Вердикт собран из сочетания нескольких сигналов проверки.</string>
<string name="narrative_reason_fallback_review">Сигналы частичные или противоречивые, поэтому нужен ручной разбор.</string>
<string name="narrative_reason_fallback_clean">Решающие сигналы обхода не найдены.</string>
@ -509,6 +512,17 @@
<!-- NativeSignsChecker -->
<string name="checker_native_category_name">Нативные признаки β</string>
<string name="checker_icmp_summary_clean">%1$s не ответил, а у %2$s RTT остался выше порога (avg %3$s)</string>
<string name="checker_icmp_summary_blocked_replied">%1$s ответил на ICMP, хотя для российского маршрута ожидается отсутствие ответа</string>
<string name="checker_icmp_summary_control_too_fast">%1$s ответил с подозрительно низким RTT (min %2$s, avg %3$s)</string>
<string name="checker_icmp_summary_both_suspicious">%1$s ответил, а у %2$s RTT оказался слишком низким (min %3$s, avg %4$s)</string>
<string name="checker_icmp_summary_inconclusive">ICMP-проверка неинформативна: %1$s не ответил</string>
<string name="checker_icmp_summary_unavailable">Проверка подмены ICMP недоступна на этом устройстве</string>
<string name="checker_icmp_target_blocked">Заблокированная цель %1$s (%2$s): ответов %3$d/%4$d</string>
<string name="checker_icmp_target_control">Контрольная цель %1$s (%2$s): ответов %3$d/%4$d, min %5$s, avg %6$s, max %7$s</string>
<string name="checker_icmp_evidence_both">%1$s ответил, а RTT у %2$s оказался ниже ожидаемого порога</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s ответил по ICMP через активный маршрут</string>
<string name="checker_icmp_evidence_control_too_fast">%1$s показал RTT ниже ожидаемого порога на активном маршруте</string>
<string name="checker_native_card_title">Нативные низкоуровневые проверки β</string>
<string name="checker_native_unavailable">Нативная библиотека не загружена; проверки недоступны.</string>
<string name="checker_native_unavailable_with_reason">Нативная библиотека не загружена: %1$s</string>

View file

@ -151,6 +151,7 @@
<string name="main_card_call_transport_stun_ipv6">IPv6</string>
<string name="main_card_call_transport_stun_no_response">无响应</string>
<string name="main_card_call_transport_stun_error">错误</string>
<string name="main_card_icmp_spoofing">ICMP 伪造 β</string>
<string name="main_card_location_signals">位置迹象</string>
<string name="main_card_native_signs">原生迹象</string>
<string name="main_verdict_details">详情</string>
@ -177,6 +178,7 @@
<string name="main_loading_geo_ip">正在检查国家、ASN 和托管迹象。</string>
<string name="main_loading_ip_comparison">正在通过多个独立服务比较公网 IP。</string>
<string name="main_loading_cdn_pulling">正在检查被封锁或 CDN 支持的端点会返回哪些网络数据。</string>
<string name="main_loading_icmp">正在通过当前活动路由解析并 ping 被封锁和对照主机。</string>
<string name="main_loading_direct">正在查找 VPN、代理和已安装客户端的直接迹象。</string>
<string name="main_loading_indirect">正在检查接口、DNS、路由和本地技术迹象。</string>
<string name="main_loading_location">正在收集运营商、基站和附近 Wi-Fi 的迹象。</string>
@ -228,6 +230,19 @@
<string name="narrative_explanation_exposure_local_proxy">只能看到设备上的本地 proxy/API看不到远程节点地址。</string>
<string name="narrative_explanation_exposure_technical">只获得了技术性网络与路由迹象,没有服务器地址。</string>
<string name="narrative_explanation_exposure_insufficient">既未获得服务器地址,也未获得公网 IP。</string>
<string name="narrative_reason_icmp_signal">检测到了异常 ICMP 响应或不现实的往返时间,需要人工复核。</string>
<string name="checker_icmp_summary_clean">%1$s 未响应,且 %2$s 保持在 RTT 阈值之上(平均 %3$s</string>
<string name="checker_icmp_summary_blocked_replied">%1$s 返回了 ICMP 响应,但按预期它在俄罗斯路由上应保持静默</string>
<string name="checker_icmp_summary_control_too_fast">%1$s 返回了可疑地偏低的 RTT最小 %2$s平均 %3$s</string>
<string name="checker_icmp_summary_both_suspicious">%1$s 已响应,且 %2$s 过快(最小 %3$s平均 %4$s</string>
<string name="checker_icmp_summary_inconclusive">ICMP 检查结论不确定,因为 %1$s 未响应</string>
<string name="checker_icmp_summary_unavailable">系统 ping 不可用,或其输出无法解析。</string>
<string name="checker_icmp_target_blocked">被封锁目标 %1$s%2$s%3$d/%4$d 次响应</string>
<string name="checker_icmp_target_control">对照目标 %1$s%2$s%3$d/%4$d 次响应,最小 %5$s平均 %6$s最大 %7$s</string>
<string name="checker_icmp_evidence_both">%1$s 已响应,且 %2$s 的 RTT 一直低于预期阈值</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s 在当前活动路由上通过 ICMP 返回了响应</string>
<string name="checker_icmp_evidence_control_too_fast">%1$s 的 RTT 在当前活动路由上一直低于预期阈值</string>
<string name="narrative_meaning_detected">红色结论表示自动检查已经收集到足够的绕过迹象。</string>
<string name="narrative_meaning_needs_review">黄色结论表示存在一些迹象,但仍需人工复核。</string>

View file

@ -1,7 +1,7 @@
<resources>
<string name="app_name">RKNHardering</string>
<string name="github_repo_url">https://github.com/xtclovver/RKNHardering</string>
<string name="run_check_notice">During a check, the app will make network requests to these domains:\n• api.beacondb.net\n• api.ipapi.is\n• www.iplocate.io\n• ip.mail.ru\n• api4.ipify.org\n• api6.ipify.org\n• 2ip.ru\n• ipv4-internet.yandex.net\n• ipv6-internet.yandex.net\n• ifconfig.me\n• ipv4.ifconfig.me\n• ipv6.ifconfig.me\n• checkip.amazonaws.com\n• api.ipify.org\n• api-ipv4.ip.sb\n• api-ipv6.ip.sb\n\nIf a custom DNS resolver is selected in settings, the app will also connect to its IP addresses or DoH domain.\n\nIf CDN pulling is enabled, the app may additionally send HTTPS requests to redirector.googlevideo.com, rutracker.org, and meduza.io to see what public IP and trace data these endpoints return.\n\nIf Call transport leak probe is enabled, the app may additionally send UDP/STUN requests to bundled Telegram targets. WhatsApp targets are used only in debug or experimental builds.\n\nIf Debug diagnostics is enabled, the app can also prepare a copyable diagnostics report from the last completed scan, even when the scan finished successfully. That report is kept only in memory until you copy it. CDN pulling responses are included in that report.\n\nBy tapping "Run check", you agree that these network requests will be made from your device.\n\nThe app does not keep logs of these requests. Network access can be disabled in the app settings.</string>
<string name="run_check_notice">During a check, the app will make network requests to these domains:\n• api.beacondb.net\n• api.ipapi.is\n• www.iplocate.io\n• ip.mail.ru\n• api4.ipify.org\n• api6.ipify.org\n• 2ip.ru\n• ipv4-internet.yandex.net\n• ipv6-internet.yandex.net\n• ifconfig.me\n• ipv4.ifconfig.me\n• ipv6.ifconfig.me\n• checkip.amazonaws.com\n• api.ipify.org\n• api-ipv4.ip.sb\n• api-ipv6.ip.sb\n\nIf a custom DNS resolver is selected in settings, the app will also connect to its IP addresses or DoH domain.\n\nIf CDN pulling is enabled, the app may additionally send HTTPS requests to redirector.googlevideo.com, rutracker.org, and meduza.io to see what public IP and trace data these endpoints return.\n\nIf Call transport leak probe is enabled, the app may additionally send UDP/STUN requests to bundled Telegram targets. WhatsApp targets are used only in debug or experimental builds.\n\nIf ICMP spoofing detection is enabled, the app may additionally resolve and ping instagram.com and google.com to compare blocked-host reachability and suspiciously low RTT values.\n\nIf Debug diagnostics is enabled, the app can also prepare a copyable diagnostics report from the last completed scan, even when the scan finished successfully. That report is kept only in memory until you copy it. CDN pulling responses are included in that report.\n\nBy tapping "Run check", you agree that these network requests will be made from your device.\n\nThe app does not keep logs of these requests. Network access can be disabled in the app settings.</string>
<string name="settings_title">Settings</string>
<string name="settings_section_checks">Checks</string>
@ -49,9 +49,9 @@
<string name="settings_port_full">Full</string>
<string name="settings_port_custom">Custom</string>
<string name="settings_network_requests">Network requests</string>
<string name="settings_network_requests_desc">GeoIP, IP comparison, CDN pulling, BeaconDB, call transport probe</string>
<string name="settings_network_requests_desc">GeoIP, IP comparison, CDN pulling, BeaconDB, call transport probe, ICMP spoofing</string>
<string name="settings_network_disable_title">Disable network requests?</string>
<string name="settings_network_disable_message">If disabled:\n\n• GeoIP checks will not run\n• IP address comparison via external services will be disabled\n• CDN pulling checks will be disabled\n• Geolocation via BeaconDB (cell towers/Wi-Fi) will be disabled\n• Call transport leak probing will be disabled\n\nOnly local checks will remain available: direct signs, indirect signs, and port scanning</string>
<string name="settings_network_disable_message">If disabled:\n\n• GeoIP checks will not run\n• IP address comparison via external services will be disabled\n• CDN pulling checks will be disabled\n• Geolocation via BeaconDB (cell towers/Wi-Fi) will be disabled\n• Call transport leak probing will be disabled\n• ICMP spoofing checks will be disabled\n\nOnly local checks will remain available: direct signs, indirect signs, and port scanning</string>
<string name="settings_cdn_pulling">CDN pulling</string>
<string name="settings_cdn_pulling_desc">Sends HTTPS requests to Google/YouTube redirector and Cloudflare trace endpoints.</string>
<string name="settings_cdn_pulling_warning_title">Enable CDN pulling?</string>
@ -151,6 +151,7 @@
<string name="main_card_call_transport_stun_ipv6">IPv6</string>
<string name="main_card_call_transport_stun_no_response">No response</string>
<string name="main_card_call_transport_stun_error">Error</string>
<string name="main_card_icmp_spoofing">ICMP spoofing β</string>
<string name="main_card_location_signals">Location signals</string>
<string name="main_card_native_signs">Native signs</string>
<string name="main_verdict_details">Details</string>
@ -177,6 +178,7 @@
<string name="main_loading_geo_ip">Checking country, ASN, and hosting indicators.</string>
<string name="main_loading_ip_comparison">Comparing the public IP through multiple independent services.</string>
<string name="main_loading_cdn_pulling">Checking what blocked or CDN-backed endpoints return about your network.</string>
<string name="main_loading_icmp">Resolving and pinging blocked and control hosts through the active route.</string>
<string name="main_loading_direct">Looking for direct signs of VPNs, proxies, and installed clients.</string>
<string name="main_loading_indirect">Checking interfaces, DNS, routes, and local technical signals.</string>
<string name="main_loading_location">Collecting carrier, cell tower, and nearby Wi-Fi signals.</string>
@ -280,6 +282,7 @@
<string name="narrative_reason_direct_signs">Direct signs of a local proxy/VPN were found.</string>
<string name="narrative_reason_indirect_signs">Indirect signs of tunneling, routing changes, or DNS replacement were found.</string>
<string name="narrative_reason_call_transport_signal">A call transport endpoint responded and exposed mapped/public network data.</string>
<string name="narrative_reason_icmp_signal">ICMP reachability or RTT looked inconsistent with a normal Russian route.</string>
<string name="narrative_reason_fallback_detected">The verdict is based on a combination of multiple check signals.</string>
<string name="narrative_reason_fallback_review">Signals are partial or contradictory, so manual review is needed.</string>
<string name="narrative_reason_fallback_clean">No decisive bypass signals were found.</string>
@ -505,6 +508,17 @@
<!-- NativeSignsChecker -->
<string name="checker_native_category_name">Native signs β</string>
<string name="checker_icmp_summary_clean">%1$s did not reply, and %2$s stayed above the RTT threshold (avg %3$s)</string>
<string name="checker_icmp_summary_blocked_replied">%1$s replied to ICMP even though it is expected to stay silent on a Russian route</string>
<string name="checker_icmp_summary_control_too_fast">%1$s replied with suspiciously low RTT (min %2$s, avg %3$s)</string>
<string name="checker_icmp_summary_both_suspicious">%1$s replied and %2$s was too fast (min %3$s, avg %4$s)</string>
<string name="checker_icmp_summary_inconclusive">ICMP check is inconclusive because %1$s did not reply</string>
<string name="checker_icmp_summary_unavailable">ICMP spoofing check is unavailable on this device</string>
<string name="checker_icmp_target_blocked">Blocked target %1$s (%2$s): %3$d/%4$d replies</string>
<string name="checker_icmp_target_control">Control target %1$s (%2$s): %3$d/%4$d replies, min %5$s, avg %6$s, max %7$s</string>
<string name="checker_icmp_evidence_both">%1$s replied and %2$s RTT stayed below the expected threshold</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s replied over ICMP on the active route</string>
<string name="checker_icmp_evidence_control_too_fast">%1$s RTT stayed below the expected threshold on the active route</string>
<string name="checker_native_card_title">Native low-level checks β</string>
<string name="checker_native_unavailable">Native library not loaded; native checks are unavailable.</string>
<string name="checker_native_unavailable_with_reason">Native library not loaded: %1$s</string>

View file

@ -66,6 +66,7 @@ internal fun exportEmptyCheckResult(): CheckResult {
),
directSigns = emptyCategory,
indirectSigns = emptyCategory,
icmpSpoofing = emptyCategory,
locationSignals = emptyCategory,
bypassResult = BypassResult(
proxyEndpoint = null,
@ -231,6 +232,30 @@ internal fun exportRichCheckResult(): CheckResult {
),
),
),
icmpSpoofing = CategoryResult(
name = "ICMP spoofing",
detected = false,
needsReview = true,
findings = listOf(
Finding(
description = "instagram.com replied and google.com was too fast",
needsReview = true,
source = EvidenceSource.ICMP_SPOOFING,
),
Finding(
description = "Blocked target instagram.com (157.240.22.174): 3/3 replies",
isInformational = true,
),
),
evidence = listOf(
EvidenceItem(
source = EvidenceSource.ICMP_SPOOFING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "ICMP route behavior looked inconsistent",
),
),
),
locationSignals = CategoryResult(
name = "Location",
detected = false,

View file

@ -36,6 +36,7 @@ class CheckResultJsonExportFormatterTest {
assertTrue(results.has("geoIp"))
assertTrue(results.has("ipComparison"))
assertTrue(results.has("cdnPulling"))
assertTrue(results.has("icmpSpoofing"))
assertTrue(results.has("bypass"))
assertTrue(results.getJSONObject("ipComparison").getJSONObject("ruGroup").has("responses"))
val outbound = results
@ -130,4 +131,24 @@ class CheckResultJsonExportFormatterTest {
assertFalse(ipConsensus.getBoolean("crossChannelMismatch"))
assertFalse(ipConsensus.getBoolean("needsReview"))
}
@Test
fun `json export includes icmp spoofing category separately`() {
val json = JSONObject(
CheckResultJsonExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = exportRichCheckResult(),
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
),
)
val icmp = json.getJSONObject("results").getJSONObject("icmpSpoofing")
assertTrue(icmp.getBoolean("needsReview"))
assertEquals("ICMP spoofing", icmp.getString("name"))
}
}

View file

@ -32,6 +32,7 @@ class CheckResultMarkdownExportFormatterTest {
assertTrue(markdown.contains("| Section | Status | Summary |"))
assertTrue(markdown.contains("## GeoIP"))
assertTrue(markdown.contains("## ${context.getString(R.string.main_card_ip_comparison)}"))
assertTrue(markdown.contains("## ${context.getString(R.string.main_card_icmp_spoofing)}"))
assertTrue(markdown.contains("## ${context.getString(R.string.settings_split_tunnel)}"))
assertTrue(markdown.contains("## Footer"))
}

View file

@ -129,6 +129,7 @@ class DebugDiagnosticsFormatterTest {
assertTrue(report.contains("debugDiagnosticsEnabled: true"))
assertTrue(report.contains("[geoIp]"))
assertTrue(report.contains("[icmpSpoofing]"))
assertTrue(report.contains("[bypass]"))
assertTrue(report.contains("[tunProbe]"))
assertTrue(report.contains("collected: false"))

View file

@ -134,6 +134,24 @@ class MainActivityUiRenderingTest {
assertEquals(activity.getString(R.string.tile_hint_loading), hint.text.toString())
}
@Test
fun `prepare check session shows loading hint for icmp tile when network checks are enabled`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
invokePrivate<Unit>(
activity,
"prepareCheckSessionUi",
CheckSettings(networkRequestsEnabled = true),
false,
)
val tiles = getPrivateField<Map<String, Any>>(activity, "tiles")
val icmpTile = tiles.getValue("icmp")
val hint = getPrivateField<TextView>(icmpTile, "hint")
assertEquals(activity.getString(R.string.tile_hint_loading), hint.text.toString())
}
@Test
fun `ip channel row shows family together with channel metadata`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()

View file

@ -0,0 +1,118 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.probe.SystemPingProber
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.IOException
@RunWith(RobolectricTestRunner::class)
class IcmpSpoofingCheckerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@After
fun tearDown() {
IcmpSpoofingChecker.dependenciesOverride = null
}
@Test
fun `blocked target without reply stays clean`() = runBlocking {
IcmpSpoofingChecker.dependenciesOverride = dependencies(
blocked = pingResult(received = 0),
control = pingResult(received = 3, min = 15.0, avg = 18.0, max = 20.0),
)
val result = IcmpSpoofingChecker.check(context, DnsResolverConfig.system())
assertFalse(result.needsReview)
assertFalse(result.hasError)
}
@Test
fun `blocked target reply yields needs review`() = runBlocking {
IcmpSpoofingChecker.dependenciesOverride = dependencies(
blocked = pingResult(received = 2),
control = pingResult(received = 3, min = 15.0, avg = 18.0, max = 20.0),
)
val result = IcmpSpoofingChecker.check(context, DnsResolverConfig.system())
assertTrue(result.needsReview)
assertTrue(result.evidence.any { it.source == EvidenceSource.ICMP_SPOOFING && it.detected })
}
@Test
fun `too fast control target yields needs review`() = runBlocking {
IcmpSpoofingChecker.dependenciesOverride = dependencies(
blocked = pingResult(received = 0),
control = pingResult(received = 3, min = 1.0, avg = 5.0, max = 7.0),
)
val result = IcmpSpoofingChecker.check(context, DnsResolverConfig.system())
assertTrue(result.needsReview)
assertFalse(result.hasError)
}
@Test
fun `unavailable ping yields error without review`() = runBlocking {
IcmpSpoofingChecker.dependenciesOverride = IcmpSpoofingChecker.Dependencies(
resolveIpv4 = { _, _ -> "203.0.113.10" },
ping = { throw IOException("ping unavailable") },
)
val result = IcmpSpoofingChecker.check(context, DnsResolverConfig.system())
assertFalse(result.needsReview)
assertTrue(result.hasError)
}
private fun dependencies(
blocked: SystemPingProber.PingResult,
control: SystemPingProber.PingResult,
): IcmpSpoofingChecker.Dependencies {
return IcmpSpoofingChecker.Dependencies(
resolveIpv4 = { host, _ ->
when (host) {
"instagram.com" -> "157.240.22.174"
"google.com" -> "8.8.8.8"
else -> error("Unexpected host $host")
}
},
ping = { address ->
when (address) {
"157.240.22.174" -> blocked
"8.8.8.8" -> control
else -> error("Unexpected address $address")
}
},
)
}
private fun pingResult(
received: Int,
min: Double? = null,
avg: Double? = null,
max: Double? = null,
): SystemPingProber.PingResult {
return SystemPingProber.PingResult(
address = "0.0.0.0",
sent = 3,
received = received,
minRttMs = min,
avgRttMs = avg,
maxRttMs = max,
exitCode = if (received > 0) 0 else 1,
rawOutput = "",
)
}
}

View file

@ -203,6 +203,20 @@ class VerdictEngineTest {
assertEquals(Verdict.NEEDS_REVIEW, verdict)
}
@Test
fun `R6 icmp spoofing review alone promotes to needs review`() {
val verdict = VerdictEngine.evaluate(
geoIp = category(),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
icmpSpoofing = category(needsReview = true),
)
assertEquals(Verdict.NEEDS_REVIEW, verdict)
}
@Test
fun `R7 empty input yields not detected`() {
val verdict = VerdictEngine.evaluate(

View file

@ -31,6 +31,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CancellationException
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
@ -150,6 +151,62 @@ class VpnCheckRunnerTest {
assertEquals(true, capturedXrayApiScanEnabled)
}
@Test
fun `icmp spoofing check runs only when network requests are enabled`() = runBlocking {
var icmpCalls = 0
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
icmpSpoofingCheck = { _, _ ->
icmpCalls += 1
category(
name = "icmp",
needsReview = true,
evidence = listOf(
EvidenceItem(
source = EvidenceSource.ICMP_SPOOFING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "ICMP looked suspicious",
),
),
)
},
directCheck = { _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ -> category("location") },
bypassCheck = { _, _, _, _, _, _, _, _, _, _ ->
error("BypassChecker should not run when split tunnel is disabled")
},
)
val disabledResult = VpnCheckRunner.run(
context = context,
settings = CheckSettings(
splitTunnelEnabled = false,
networkRequestsEnabled = false,
resolverConfig = DnsResolverConfig.system(),
),
)
assertEquals(0, icmpCalls)
assertFalse(disabledResult.icmpSpoofing.needsReview)
val enabledResult = VpnCheckRunner.run(
context = context,
settings = CheckSettings(
splitTunnelEnabled = false,
networkRequestsEnabled = true,
resolverConfig = DnsResolverConfig.system(),
),
)
assertEquals(1, icmpCalls)
assertTrue(enabledResult.icmpSpoofing.needsReview)
assertEquals(Verdict.NEEDS_REVIEW, enabledResult.verdict)
}
@Test
fun `shared underlying probe reaches direct and bypass checks`() = runBlocking {
val sharedProbe = UnderlyingNetworkProber.ProbeResult(

View file

@ -0,0 +1,126 @@
package com.notcvnt.rknhardering.probe
import java.io.IOException
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.After
import org.junit.Test
class SystemPingProberTest {
@After
fun tearDown() {
SystemPingProber.runCommandOverride = null
}
@Test
fun `parse extracts summary and rtt from standard ping output`() {
val result = SystemPingProber.parse(
address = "8.8.8.8",
commandResult = SystemPingProber.CommandResult(
exitCode = 0,
output = """
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=117 time=12.3 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=117 time=11.1 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=117 time=10.9 ms
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 10.9/11.4/12.3/0.6 ms
""".trimIndent(),
),
)
assertEquals(3, result.sent)
assertEquals(3, result.received)
assertEquals(10.9, result.minRttMs ?: -1.0, 0.0)
assertEquals(11.4, result.avgRttMs ?: -1.0, 0.0)
assertEquals(12.3, result.maxRttMs ?: -1.0, 0.0)
assertTrue(result.hasReplies)
}
@Test
fun `parse keeps zero reply summary when rtt line is absent`() {
val result = SystemPingProber.parse(
address = "157.240.22.174",
commandResult = SystemPingProber.CommandResult(
exitCode = 1,
output = """
PING 157.240.22.174 (157.240.22.174): 56 data bytes
--- 157.240.22.174 ping statistics ---
3 packets transmitted, 0 packets received, 100% packet loss
""".trimIndent(),
),
)
assertEquals(3, result.sent)
assertEquals(0, result.received)
assertFalse(result.hasReplies)
}
@Test
fun `probe includes command exit code and output preview when parse fails`() {
SystemPingProber.runCommandOverride = {
SystemPingProber.CommandResult(
exitCode = 0,
output = """
PING 8.8.8.8 (8.8.8.8)
reply from 8.8.8.8 with unsupported timing field
unexpected summary format
""".trimIndent(),
)
}
val error = assertThrows(IOException::class.java) {
runBlocking {
SystemPingProber.probe(address = "8.8.8.8")
}
}
assertTrue(error.message.orEmpty().contains("command="))
assertTrue(error.message.orEmpty().contains("exitCode=0"))
assertTrue(error.message.orEmpty().contains("unexpected summary format"))
}
@Test
fun `probe falls back to command without force ipv4 flag when dash4 is unsupported`() {
val attemptedCommands = mutableListOf<List<String>>()
SystemPingProber.runCommandOverride = { command ->
attemptedCommands += command
if ("-4" in command) {
SystemPingProber.CommandResult(
exitCode = 2,
output = "ping: invalid option -- 4",
)
} else {
SystemPingProber.CommandResult(
exitCode = 0,
output = """
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=117 time=12.3 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=117 time=11.1 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=117 time=10.9 ms
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 10.9/11.4/12.3/0.6 ms
""".trimIndent(),
)
}
}
val result = runBlocking {
SystemPingProber.probe(address = "8.8.8.8")
}
assertTrue(attemptedCommands.any { "-4" in it })
assertTrue(attemptedCommands.any { "-4" !in it })
assertEquals(3, result.received)
assertTrue(result.hasReplies)
}
}