fix: curl probing метод теперь должен работать как задуманно

Closes #31
This commit is contained in:
xtclovver 2026-04-16 04:29:30 +03:00
parent 2e5f64c7e4
commit 11849f2c1b
32 changed files with 4732 additions and 43 deletions

View file

@ -1,5 +1,10 @@
name: CI
env:
ANDROID_BUILD_TOOLS_VERSION: "36.0.0"
ANDROID_CMAKE_VERSION: "3.22.1"
ANDROID_NDK_VERSION: "28.2.13676358"
on:
push:
branches: [ "main" ]
@ -13,6 +18,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
@ -20,6 +28,16 @@ jobs:
distribution: 'temurin'
cache: gradle
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null
sdkmanager \
"platforms;android-36" \
"platform-tools" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;${ANDROID_CMAKE_VERSION}"
- name: Grant execute permission for gradlew
run: chmod +x gradlew

View file

@ -1,5 +1,10 @@
name: Release
env:
ANDROID_BUILD_TOOLS_VERSION: "36.0.0"
ANDROID_CMAKE_VERSION: "3.22.1"
ANDROID_NDK_VERSION: "28.2.13676358"
on:
push:
tags:
@ -22,6 +27,9 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Resolve version
id: version
run: |
@ -49,6 +57,16 @@ jobs:
distribution: 'temurin'
cache: gradle
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null
sdkmanager \
"platforms;android-36" \
"platform-tools" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;${ANDROID_CMAKE_VERSION}"
- name: Grant execute permission for gradlew
run: chmod +x gradlew

1
.idea/misc.xml generated
View file

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View file

@ -4,6 +4,9 @@ plugins {
alias(libs.plugins.android.application)
}
val nativeNdkVersion = "28.2.13676358"
val nativeCmakeVersion = "3.22.1"
val appVersionName = resolveAppVersionName()
val appVersionCode = calculateAppVersionCode(appVersionName)
@ -35,6 +38,7 @@ fun calculateAppVersionCode(versionName: String): Int {
android {
namespace = "com.notcvnt.rknhardering"
ndkVersion = nativeNdkVersion
compileSdk {
version = release(36) {
minorApiLevel = 1
@ -50,6 +54,16 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
externalNativeBuild {
cmake {
arguments += listOf("-DANDROID_STL=c++_static")
}
}
}
buildTypes {
@ -68,6 +82,17 @@ android {
buildFeatures {
buildConfig = true
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = nativeCmakeVersion
}
}
packaging {
jniLibs {
useLegacyPackaging = false
}
}
testOptions {
unitTests.isIncludeAndroidResources = true
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
b6e66569cc3d438dd5abe514d0df50005d570bfc96c14dca8f768d020cb96171

View file

@ -0,0 +1,77 @@
cmake_minimum_required(VERSION 3.22.1)
project(native_curl_probe LANGUAGES C CXX)
include(FetchContent)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_STATIC_LIBS ON CACHE BOOL "" FORCE)
set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL "" FORCE)
FetchContent_Declare(
mbedtls
URL ${CMAKE_CURRENT_SOURCE_DIR}/third_party/mbedtls-3.6.3.zip
URL_HASH SHA256=2597afb8b0f3f552e27bb2bfa172350983aaee87c98ca35849fafc38d5c770ba
)
set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE)
set(ENABLE_TESTING OFF CACHE BOOL "" FORCE)
set(MBEDTLS_FATAL_WARNINGS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(mbedtls)
# curl's FindMbedTLS.cmake expects find_package-style variables, but we vendor
# mbedTLS via FetchContent in the same build. Point curl at the in-tree targets.
set(MBEDTLS_USE_STATIC_LIBS ON CACHE BOOL "" FORCE)
set(MBEDTLS_INCLUDE_DIR "${mbedtls_SOURCE_DIR}/include" CACHE PATH "" FORCE)
set(MBEDTLS_LIBRARY "mbedtls" CACHE STRING "" FORCE)
set(MBEDX509_LIBRARY "mbedx509" CACHE STRING "" FORCE)
set(MBEDCRYPTO_LIBRARY "mbedcrypto" CACHE STRING "" FORCE)
FetchContent_Declare(
curl
URL ${CMAKE_CURRENT_SOURCE_DIR}/third_party/curl-8.19.0.zip
URL_HASH SHA256=c2842525900529f866ac50ccb6dc175ed317fa1025b439dcf84d089100effdb9
)
set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE)
set(CURL_DISABLE_INSTALL ON CACHE BOOL "" FORCE)
set(CURL_ENABLE_SSL ON CACHE BOOL "" FORCE)
set(CURL_USE_MBEDTLS ON CACHE BOOL "" FORCE)
set(CURL_USE_OPENSSL OFF CACHE BOOL "" FORCE)
set(CURL_USE_LIBPSL OFF CACHE BOOL "" FORCE)
set(ENABLE_ARES OFF CACHE BOOL "" FORCE)
set(USE_NGHTTP2 OFF CACHE BOOL "" FORCE)
set(HTTP_ONLY ON CACHE BOOL "" FORCE)
set(CURL_DISABLE_PROXY ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(curl)
add_library(
native_curl_probe
SHARED
native_curl_probe.cpp
)
target_include_directories(
native_curl_probe
PRIVATE
${curl_SOURCE_DIR}/include
${mbedtls_SOURCE_DIR}/include
)
target_compile_definitions(native_curl_probe PRIVATE CURL_STATICLIB)
target_link_libraries(
native_curl_probe
PRIVATE
libcurl
mbedcrypto
mbedtls
mbedx509
log
)

View file

@ -0,0 +1,234 @@
#include "native_curl_probe.h"
#include <curl/curl.h>
#include <jni.h>
#include <mutex>
#include <sstream>
#include <string>
#include <vector>
namespace {
constexpr jint kResultSize = 6;
struct CurlExecutionResult {
CURLcode curl_code = CURLE_OK;
long http_code = 0;
std::string body;
std::string error_buffer;
std::string resolved_addresses_csv;
std::string local_error;
};
std::once_flag g_curl_init_once;
void EnsureCurlGlobalInit() {
std::call_once(g_curl_init_once, []() { curl_global_init(CURL_GLOBAL_DEFAULT); });
}
std::string JStringToStdString(JNIEnv* env, jstring value) {
if (value == nullptr) {
return {};
}
const char* chars = env->GetStringUTFChars(value, nullptr);
if (chars == nullptr) {
return {};
}
std::string result(chars);
env->ReleaseStringUTFChars(value, chars);
return result;
}
void SetArrayValue(JNIEnv* env, jobjectArray array, jsize index, const std::string& value) {
if (value.empty()) {
return;
}
jstring string_value = env->NewStringUTF(value.c_str());
env->SetObjectArrayElement(array, index, string_value);
env->DeleteLocalRef(string_value);
}
size_t WriteCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
auto* output = reinterpret_cast<std::string*>(userdata);
output->append(ptr, size * nmemb);
return size * nmemb;
}
long CurlIpResolveMode(jint mode) {
switch (mode) {
case 1:
return CURL_IPRESOLVE_V4;
case 2:
return CURL_IPRESOLVE_V6;
default:
return CURL_IPRESOLVE_WHATEVER;
}
}
std::string AddressesFromResolveRule(const std::string& rule) {
const size_t first = rule.find(':');
if (first == std::string::npos) {
return {};
}
const size_t second = rule.find(':', first + 1);
if (second == std::string::npos || second + 1 >= rule.size()) {
return {};
}
return rule.substr(second + 1);
}
CurlExecutionResult ExecuteRequest(
const std::string& url,
const std::string& interface_name,
const std::vector<std::string>& resolve_rules,
jint ip_resolve_mode,
jint timeout_ms,
jint connect_timeout_ms,
const std::string& ca_bundle_path,
bool debug_verbose) {
CurlExecutionResult result;
if (url.empty()) {
result.local_error = "url is empty";
return result;
}
if (interface_name.empty()) {
result.local_error = "interfaceName is empty";
return result;
}
if (ca_bundle_path.empty()) {
result.local_error = "caBundlePath is empty";
return result;
}
EnsureCurlGlobalInit();
CURL* handle = curl_easy_init();
if (handle == nullptr) {
result.local_error = "curl_easy_init failed";
return result;
}
curl_slist* resolve_list = nullptr;
for (const std::string& rule : resolve_rules) {
resolve_list = curl_slist_append(resolve_list, rule.c_str());
if (result.resolved_addresses_csv.empty()) {
result.resolved_addresses_csv = AddressesFromResolveRule(rule);
}
}
char error_buffer[CURL_ERROR_SIZE] = {0};
std::string response_body;
const std::string interface_option = "if!" + interface_name;
curl_easy_setopt(handle, CURLOPT_URL, url.c_str());
curl_easy_setopt(handle, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(handle, CURLOPT_TIMEOUT_MS, static_cast<long>(timeout_ms));
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(connect_timeout_ms));
curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, WriteCallback);
curl_easy_setopt(handle, CURLOPT_WRITEDATA, &response_body);
curl_easy_setopt(handle, CURLOPT_ERRORBUFFER, error_buffer);
curl_easy_setopt(handle, CURLOPT_CAINFO, ca_bundle_path.c_str());
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(handle, CURLOPT_IPRESOLVE, CurlIpResolveMode(ip_resolve_mode));
curl_easy_setopt(handle, CURLOPT_INTERFACE, interface_option.c_str());
curl_easy_setopt(handle, CURLOPT_PROXY, "");
curl_easy_setopt(handle, CURLOPT_NOPROXY, "*");
curl_easy_setopt(handle, CURLOPT_VERBOSE, debug_verbose ? 1L : 0L);
if (resolve_list != nullptr) {
curl_easy_setopt(handle, CURLOPT_RESOLVE, resolve_list);
}
result.curl_code = curl_easy_perform(handle);
curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &result.http_code);
char* primary_ip = nullptr;
if (curl_easy_getinfo(handle, CURLINFO_PRIMARY_IP, &primary_ip) == CURLE_OK &&
primary_ip != nullptr && result.resolved_addresses_csv.empty()) {
result.resolved_addresses_csv = primary_ip;
}
result.body = response_body;
result.error_buffer = error_buffer;
if (result.error_buffer.empty() && result.curl_code != CURLE_OK) {
result.error_buffer = curl_easy_strerror(result.curl_code);
}
curl_slist_free_all(resolve_list);
curl_easy_cleanup(handle);
return result;
}
} // namespace
jobjectArray ExecuteNativeCurlRequest(
JNIEnv* env,
jstring url,
jstring interface_name,
jobjectArray resolve_rules,
jint ip_resolve_mode,
jint timeout_ms,
jint connect_timeout_ms,
jstring ca_bundle_path,
jboolean debug_verbose) {
std::vector<std::string> parsed_rules;
if (resolve_rules != nullptr) {
const jsize rule_count = env->GetArrayLength(resolve_rules);
parsed_rules.reserve(static_cast<size_t>(rule_count));
for (jsize index = 0; index < rule_count; ++index) {
auto* rule = static_cast<jstring>(env->GetObjectArrayElement(resolve_rules, index));
parsed_rules.push_back(JStringToStdString(env, rule));
env->DeleteLocalRef(rule);
}
}
const CurlExecutionResult result = ExecuteRequest(
JStringToStdString(env, url),
JStringToStdString(env, interface_name),
parsed_rules,
ip_resolve_mode,
timeout_ms,
connect_timeout_ms,
JStringToStdString(env, ca_bundle_path),
debug_verbose == JNI_TRUE);
jclass string_class = env->FindClass("java/lang/String");
jobjectArray output = env->NewObjectArray(kResultSize, string_class, nullptr);
SetArrayValue(env, output, 0, result.curl_code == CURLE_OK ? "0" : std::to_string(result.curl_code));
SetArrayValue(env, output, 1, result.http_code > 0 ? std::to_string(result.http_code) : "");
SetArrayValue(env, output, 2, result.body);
SetArrayValue(env, output, 3, result.error_buffer);
SetArrayValue(env, output, 4, result.resolved_addresses_csv);
SetArrayValue(env, output, 5, result.local_error);
return output;
}
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_notcvnt_rknhardering_probe_NativeCurlBridge_nativeExecuteRaw(
JNIEnv* env,
jobject /* this */,
jstring url,
jstring interface_name,
jobjectArray resolve_rules,
jint ip_resolve_mode,
jint timeout_ms,
jint connect_timeout_ms,
jstring ca_bundle_path,
jboolean debug_verbose) {
return ExecuteNativeCurlRequest(
env,
url,
interface_name,
resolve_rules,
ip_resolve_mode,
timeout_ms,
connect_timeout_ms,
ca_bundle_path,
debug_verbose);
}

View file

@ -0,0 +1,19 @@
#ifndef RKNHARDERING_NATIVE_CURL_PROBE_H
#define RKNHARDERING_NATIVE_CURL_PROBE_H
#include <jni.h>
jobjectArray ExecuteNativeCurlRequest(
JNIEnv* env,
jstring url,
jstring interface_name,
jobjectArray resolve_rules,
jint ip_resolve_mode,
jint timeout_ms,
jint connect_timeout_ms,
jstring ca_bundle_path,
jboolean debug_verbose
);
#endif

Binary file not shown.

Binary file not shown.

View file

@ -5,11 +5,14 @@ import com.notcvnt.rknhardering.checker.CheckSettings
import com.notcvnt.rknhardering.network.DnsResolverMode
import com.notcvnt.rknhardering.probe.PublicIpProbeMode
import com.notcvnt.rknhardering.probe.PublicIpProbeStatus
import com.notcvnt.rknhardering.probe.PublicIpTransportDiagnostics
import com.notcvnt.rknhardering.probe.TunEndpointAttempt
import com.notcvnt.rknhardering.probe.TunProbeAttemptDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeEngine
import com.notcvnt.rknhardering.probe.TunProbeModeOverride
import com.notcvnt.rknhardering.probe.TunProbePathDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeResolveStrategy
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -52,7 +55,7 @@ object TunProbeDiagnosticsFormatter {
modeOverride: TunProbeModeOverride,
path: TunProbePathDiagnostics,
): String {
return context.getString(
val summary = context.getString(
R.string.checker_tun_probe_debug_summary,
pathLabel,
effectiveModeLabel(context, modeOverride, path),
@ -61,6 +64,12 @@ object TunProbeDiagnosticsFormatter {
statusLabel(context, path.curlCompatible.status),
path.dnsPathMismatch.toString(),
)
val diagnostics = path.curlCompatible.transportDiagnostics
return if (diagnostics.engine == null && diagnostics.resolveStrategy == null) {
summary
} else {
"$summary, curlEngine ${engineLabel(diagnostics.engine)}, curlResolve ${resolveStrategyLabel(diagnostics.resolveStrategy)}"
}
}
fun effectiveModeLabel(
@ -121,6 +130,7 @@ object TunProbeDiagnosticsFormatter {
builder.appendLine("$label.status: ${attempt.status}")
builder.appendLine("$label.ip: ${attempt.ip?.let(::maskIp) ?: "<none>"}")
builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: "<none>"}")
appendTransportDiagnostics(builder, label, attempt.transportDiagnostics)
builder.appendLine("$label.endpoints:")
if (attempt.endpointAttempts.isEmpty()) {
builder.appendLine("- <none>")
@ -141,6 +151,46 @@ object TunProbeDiagnosticsFormatter {
return "${attempt.endpoint} [${attempt.familyHint}] -> ${attempt.status} ($result)"
}
private fun appendTransportDiagnostics(
builder: StringBuilder,
label: String,
diagnostics: PublicIpTransportDiagnostics,
) {
diagnostics.engine?.let {
builder.appendLine("$label.engine: ${engineLabel(it)}")
}
diagnostics.resolveStrategy?.let {
builder.appendLine("$label.resolveStrategy: ${resolveStrategyLabel(it)}")
}
diagnostics.curlCode?.let {
builder.appendLine("$label.curlCode: $it")
}
diagnostics.httpCode?.let {
builder.appendLine("$label.httpCode: $it")
}
diagnostics.nativeLibraryLoaded?.let {
builder.appendLine("$label.nativeLibraryLoaded: $it")
}
diagnostics.caBundleVersion?.let {
builder.appendLine("$label.caBundleVersion: $it")
}
if (diagnostics.resolvedAddressesUsed.isNotEmpty()) {
builder.appendLine(
"$label.resolvedAddresses: ${
diagnostics.resolvedAddressesUsed.joinToString(", ") { maskIp(it) }
}",
)
}
}
private fun engineLabel(engine: TunProbeEngine?): String {
return engine?.debugName ?: "unknown"
}
private fun resolveStrategyLabel(strategy: TunProbeResolveStrategy?): String {
return strategy?.debugName ?: "unknown"
}
private fun describeResolver(settings: CheckSettings): String {
return when (settings.resolverConfig.mode) {
DnsResolverMode.SYSTEM -> "SYSTEM"

View file

@ -28,6 +28,7 @@ import com.notcvnt.rknhardering.probe.ProxyScanner
import com.notcvnt.rknhardering.probe.ProxyType
import com.notcvnt.rknhardering.probe.ScanMode
import com.notcvnt.rknhardering.probe.ScanPhase
import com.notcvnt.rknhardering.probe.TunProbeResolveStrategy
import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayApiScanner
@ -803,24 +804,35 @@ object BypassChecker {
findings: MutableList<Finding>,
): Boolean {
val pathLabels = mutableListOf<String>()
var usesInjectedResolve = false
if (
result.vpnIp != null &&
result.vpnIpComparison?.usedCurlCompatibleFallback() == true
) {
pathLabels += context.getString(R.string.checker_bypass_transport_only_vpn_path)
usesInjectedResolve = usesInjectedResolve ||
result.vpnIpComparison.curlCompatible.transportDiagnostics.resolveStrategy ==
TunProbeResolveStrategy.KOTLIN_INJECTED
}
if (
result.underlyingIp != null &&
result.underlyingIpComparison?.usedCurlCompatibleFallback() == true
) {
pathLabels += context.getString(R.string.checker_bypass_transport_only_underlying_path)
usesInjectedResolve = usesInjectedResolve ||
result.underlyingIpComparison.curlCompatible.transportDiagnostics.resolveStrategy ==
TunProbeResolveStrategy.KOTLIN_INJECTED
}
if (pathLabels.isEmpty()) return false
findings.add(
Finding(
description = context.getString(
R.string.checker_bypass_transport_only_used,
if (usesInjectedResolve) {
R.string.checker_bypass_curl_compatible_used
} else {
R.string.checker_bypass_transport_only_used
},
pathLabels.joinToString(", "),
),
isInformational = true,

View file

@ -22,6 +22,7 @@ import com.notcvnt.rknhardering.probe.IfconfigClient
import com.notcvnt.rknhardering.probe.LocalSocketInspector
import com.notcvnt.rknhardering.probe.LocalSocketListener
import com.notcvnt.rknhardering.probe.MtProtoProber
import com.notcvnt.rknhardering.probe.NativeCurlBridge
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.ProxyScanner
import com.notcvnt.rknhardering.probe.ProxyType
@ -122,6 +123,7 @@ object CallTransportChecker {
if (!callTransportEnabled) {
return@withContext Evaluation()
}
NativeCurlBridge.initIfNeeded(context)
val dependencies = dependenciesOverride ?: Dependencies()
val results = mutableListOf<CallTransportLeakResult>()
@ -716,9 +718,9 @@ object CallTransportChecker {
confidence = result.confidence,
)
}
CallTransportStatus.BASELINE,
CallTransportStatus.NO_SIGNAL,
CallTransportStatus.UNSUPPORTED,
CallTransportStatus.BASELINE,
-> Unit
}
}

View file

@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.network.NetworkInterfaceNameNormalizer
import com.notcvnt.rknhardering.probe.PublicIpProbeMode
import com.notcvnt.rknhardering.probe.TunProbeResolveStrategy
import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.vpn.InstalledVpnAppDetector
@ -644,10 +645,14 @@ object DirectSignsChecker {
): SignalOutcome {
val comparison = result.vpnIpComparison
result.vpnIp?.let { vpnIp ->
val transportOnly = comparison?.usedCurlCompatibleFallback() == true
val transportOnly = comparison?.usedCurlCompatibleFallback() == true &&
comparison.curlCompatible.transportDiagnostics.resolveStrategy != TunProbeResolveStrategy.KOTLIN_INJECTED
val description = context.getString(
if (transportOnly) R.string.checker_bypass_tun_probe_success_transport_only
else R.string.checker_bypass_tun_probe_success,
when {
transportOnly -> R.string.checker_bypass_tun_probe_success_transport_only
comparison?.usedCurlCompatibleFallback() == true -> R.string.checker_bypass_tun_probe_success_curl_compatible
else -> R.string.checker_bypass_tun_probe_success
},
vpnIp,
)
val confidence = if (transportOnly) EvidenceConfidence.MEDIUM else EvidenceConfidence.HIGH
@ -676,7 +681,11 @@ object DirectSignsChecker {
findings.add(
Finding(
description = context.getString(
R.string.checker_bypass_tun_probe_dns_mismatch,
if (transportOnly) {
R.string.checker_bypass_tun_probe_dns_mismatch
} else {
R.string.checker_bypass_tun_probe_dns_mismatch_curl_compatible
},
comparison.strict.error ?: result.vpnError ?: "unknown error",
),
needsReview = true,

View file

@ -1,6 +1,8 @@
package com.notcvnt.rknhardering.checker
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CallTransportNetworkPath
import com.notcvnt.rknhardering.model.CallTransportStatus
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Verdict
@ -69,10 +71,17 @@ object VerdictEngine {
!geoMatrixHit && directMatrixHit && indirectMatrixHit -> Verdict.NEEDS_REVIEW
else -> Verdict.DETECTED
}
val hasActionableCallTransportLeak = indirectSigns.callTransportLeaks.any {
it.status == CallTransportStatus.NEEDS_REVIEW &&
it.networkPath != CallTransportNetworkPath.LOCAL_PROXY
}
if (bypassResult.needsReview && matrixVerdict == Verdict.NOT_DETECTED) {
return Verdict.NEEDS_REVIEW
}
if (hasActionableCallTransportLeak && matrixVerdict == Verdict.NOT_DETECTED) {
return Verdict.NEEDS_REVIEW
}
return matrixVerdict
}

View file

@ -221,6 +221,16 @@ object IfconfigClient {
binding: ResolverBinding,
collectTrace: Boolean = false,
): PublicIpModeProbeResult {
if (mode == PublicIpProbeMode.CURL_COMPATIBLE && binding is ResolverBinding.OsDeviceBinding) {
return NativeCurlTransportProbe.fetchModeProbeResult(
mode = mode,
endpoints = ENDPOINTS,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
binding = binding,
collectTrace = collectTrace,
)
}
val endpointAttempts = if (collectTrace) mutableListOf<TunEndpointAttempt>() else null
val result = fetchIpForBinding(
timeoutMs = timeoutMs,

View file

@ -0,0 +1,38 @@
package com.notcvnt.rknhardering.probe
import android.content.Context
import java.io.File
internal object NativeCaBundle {
private const val ASSET_DIR = "native-curl"
private const val ASSET_NAME = "cacert.pem"
private const val HASH_ASSET_NAME = "cacert.pem.sha256"
data class Info(
val absolutePath: String,
val versionHash: String,
)
fun ensureInstalled(context: Context): Info {
val versionHash = context.assets
.open("$ASSET_DIR/$HASH_ASSET_NAME")
.bufferedReader(Charsets.UTF_8)
.use { it.readText() }
.trim()
val targetDir = File(context.filesDir, "native-ca")
if (!targetDir.exists()) {
targetDir.mkdirs()
}
val targetFile = File(targetDir, "cacert-$versionHash.pem")
if (!targetFile.exists()) {
context.assets.open("$ASSET_DIR/$ASSET_NAME").use { input ->
targetFile.outputStream().use { output -> input.copyTo(output) }
}
}
return Info(
absolutePath = targetFile.absolutePath,
versionHash = versionHash,
)
}
}

View file

@ -0,0 +1,124 @@
package com.notcvnt.rknhardering.probe
import android.content.Context
import org.json.JSONObject
object NativeCurlBridge {
private const val LIBRARY_NAME = "native_curl_probe"
@Volatile
internal var initOverride: ((Context) -> NativeCaBundle.Info)? = null
@Volatile
internal var executeOverride: ((NativeCurlRequest) -> NativeCurlResponse)? = null
@Volatile
internal var isLibraryLoadedOverride: (() -> Boolean)? = null
@Volatile
private var initialized = false
@Volatile
private var libraryLoaded = false
@Volatile
private var lastLoadError: Throwable? = null
@Volatile
private var caBundleInfo: NativeCaBundle.Info? = null
fun initIfNeeded(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
val appContext = context.applicationContext
try {
val override = initOverride
if (override != null) {
caBundleInfo = override(appContext)
libraryLoaded = isLibraryLoadedOverride?.invoke() ?: true
lastLoadError = null
} else {
caBundleInfo = NativeCaBundle.ensureInstalled(appContext)
System.loadLibrary(LIBRARY_NAME)
libraryLoaded = true
lastLoadError = null
}
} catch (error: Throwable) {
libraryLoaded = false
lastLoadError = error
} finally {
initialized = true
}
}
}
fun isLibraryLoaded(): Boolean {
return isLibraryLoadedOverride?.invoke() ?: libraryLoaded
}
fun execute(requestJson: String): String {
val request = NativeCurlRequest.fromJson(requestJson)
return execute(request).toJson()
}
internal fun execute(request: NativeCurlRequest): NativeCurlResponse {
executeOverride?.let { return it(request) }
if (!isLibraryLoaded()) {
return NativeCurlResponse(localError = lastLoadErrorMessage() ?: "Native curl bridge is not loaded")
}
val activeCaBundle = request.caBundlePath?.takeIf { it.isNotBlank() } ?: caBundleInfo?.absolutePath
if (activeCaBundle.isNullOrBlank()) {
return NativeCurlResponse(localError = "Native CA bundle is unavailable")
}
val raw = nativeExecuteRaw(
url = request.url,
interfaceName = request.interfaceName,
resolveRules = request.resolveRules.map(NativeCurlResolveRule::toCurlRule).toTypedArray(),
ipResolveMode = request.ipResolveMode.nativeValue,
timeoutMs = request.timeoutMs,
connectTimeoutMs = request.connectTimeoutMs,
caBundlePath = activeCaBundle,
debugVerbose = request.debugVerbose,
)
return NativeCurlResponse.fromRaw(raw)
}
internal fun lastLoadErrorMessage(): String? {
val error = lastLoadError ?: return null
return error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
}
internal fun caBundleInfo(): NativeCaBundle.Info? = caBundleInfo
internal fun debugSnapshot(): JSONObject {
return JSONObject().apply {
put("libraryLoaded", isLibraryLoaded())
put("loadError", lastLoadErrorMessage())
put("caBundleVersion", caBundleInfo?.versionHash)
put("caBundlePath", caBundleInfo?.absolutePath)
}
}
internal fun resetForTests() {
initOverride = null
executeOverride = null
isLibraryLoadedOverride = null
initialized = false
libraryLoaded = false
lastLoadError = null
caBundleInfo = null
}
private external fun nativeExecuteRaw(
url: String,
interfaceName: String,
resolveRules: Array<String>,
ipResolveMode: Int,
timeoutMs: Int,
connectTimeoutMs: Int,
caBundlePath: String,
debugVerbose: Boolean,
): Array<String?>
}

View file

@ -0,0 +1,152 @@
package com.notcvnt.rknhardering.probe
import org.json.JSONArray
import org.json.JSONObject
internal enum class NativeCurlIpResolveMode(val wireValue: String) {
WHATEVER("whatever"),
V4("v4"),
V6("v6");
val nativeValue: Int
get() = when (this) {
WHATEVER -> 0
V4 -> 1
V6 -> 2
}
companion object {
fun fromWireValue(value: String?): NativeCurlIpResolveMode {
return entries.firstOrNull { it.wireValue == value } ?: WHATEVER
}
}
}
internal data class NativeCurlResolveRule(
val host: String,
val port: Int,
val addresses: List<String>,
) {
fun toCurlRule(): String = "$host:$port:${addresses.joinToString(",")}"
companion object {
fun fromJson(json: JSONObject): NativeCurlResolveRule {
return NativeCurlResolveRule(
host = json.getString("host"),
port = json.optInt("port", 443),
addresses = json.optJSONArray("addresses").toStringList(),
)
}
}
}
internal data class NativeCurlRequest(
val url: String,
val interfaceName: String,
val resolveRules: List<NativeCurlResolveRule> = emptyList(),
val ipResolveMode: NativeCurlIpResolveMode = NativeCurlIpResolveMode.WHATEVER,
val timeoutMs: Int,
val connectTimeoutMs: Int,
val caBundlePath: String,
val debugVerbose: Boolean,
) {
companion object {
fun fromJson(raw: String): NativeCurlRequest {
val json = JSONObject(raw)
return NativeCurlRequest(
url = json.getString("url"),
interfaceName = json.getString("interfaceName"),
resolveRules = json.optJSONArray("resolveRules").toResolveRuleList(),
ipResolveMode = NativeCurlIpResolveMode.fromWireValue(
json.optString("ipResolveMode").takeIf { it.isNotBlank() },
),
timeoutMs = json.optInt("timeoutMs", 7_000),
connectTimeoutMs = json.optInt("connectTimeoutMs", json.optInt("timeoutMs", 7_000)),
caBundlePath = json.optString("caBundlePath", ""),
debugVerbose = json.optBoolean("debugVerbose", false),
)
}
}
fun toJson(): String {
val json = JSONObject()
json.put("url", url)
json.put("interfaceName", interfaceName)
json.put("ipResolveMode", ipResolveMode.wireValue)
json.put("timeoutMs", timeoutMs)
json.put("connectTimeoutMs", connectTimeoutMs)
json.put("caBundlePath", caBundlePath)
json.put("debugVerbose", debugVerbose)
json.put(
"resolveRules",
JSONArray().apply {
resolveRules.forEach { rule ->
put(
JSONObject().apply {
put("host", rule.host)
put("port", rule.port)
put("addresses", JSONArray(rule.addresses))
},
)
}
},
)
return json.toString()
}
}
internal data class NativeCurlResponse(
val curlCode: Int? = null,
val httpCode: Int? = null,
val body: String = "",
val errorBuffer: String? = null,
val resolvedAddressesUsed: List<String> = emptyList(),
val localError: String? = null,
) {
companion object {
fun fromRaw(raw: Array<String?>): NativeCurlResponse {
val values = raw.toList()
return NativeCurlResponse(
curlCode = values.getOrNull(0)?.toIntOrNull(),
httpCode = values.getOrNull(1)?.toIntOrNull(),
body = values.getOrNull(2).orEmpty(),
errorBuffer = values.getOrNull(3)?.takeIf { it.isNotBlank() },
resolvedAddressesUsed = values.getOrNull(4)
?.split(',')
?.map(String::trim)
?.filter(String::isNotEmpty)
.orEmpty(),
localError = values.getOrNull(5)?.takeIf { it.isNotBlank() },
)
}
}
fun toJson(): String {
return JSONObject().apply {
curlCode?.let { put("curlCode", it) }
httpCode?.let { put("httpCode", it) }
put("body", body)
errorBuffer?.let { put("errorBuffer", it) }
put("resolvedAddressesUsed", JSONArray(resolvedAddressesUsed))
localError?.let { put("localError", it) }
}.toString()
}
}
private fun JSONArray?.toStringList(): List<String> {
if (this == null) return emptyList()
return buildList(length()) {
for (index in 0 until length()) {
optString(index).takeIf { it.isNotBlank() }?.let(::add)
}
}
}
private fun JSONArray?.toResolveRuleList(): List<NativeCurlResolveRule> {
if (this == null) return emptyList()
return buildList(length()) {
for (index in 0 until length()) {
optJSONObject(index)?.let { add(NativeCurlResolveRule.fromJson(it)) }
}
}
}

View file

@ -0,0 +1,194 @@
package com.notcvnt.rknhardering.probe
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.DnsResolverMode
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import java.io.IOException
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.URL
internal object NativeCurlTransportProbe {
private const val DEFAULT_PORT = 443
suspend fun fetchModeProbeResult(
mode: PublicIpProbeMode,
endpoints: List<IpEndpointSpec>,
timeoutMs: Int,
resolverConfig: DnsResolverConfig,
binding: com.notcvnt.rknhardering.network.ResolverBinding.OsDeviceBinding,
collectTrace: Boolean,
): PublicIpModeProbeResult {
val endpointAttempts = if (collectTrace) mutableListOf<TunEndpointAttempt>() else null
var lastError: String? = null
var lastDiagnostics = PublicIpTransportDiagnostics(
engine = TunProbeEngine.NATIVE_LIBCURL,
resolveStrategy = TunProbeResolveStrategy.NATIVE_DEFAULT,
nativeLibraryLoaded = NativeCurlBridge.isLibraryLoaded(),
caBundleVersion = NativeCurlBridge.caBundleInfo()?.versionHash,
)
for (endpoint in endpoints) {
val resolveConfig = try {
resolveConfigFor(endpoint, resolverConfig, binding)
} catch (error: Exception) {
val diagnostics = diagnosticsFor(
resolveStrategy = resolveStrategyFor(resolverConfig),
response = NativeCurlResponse(localError = error.renderMessage()),
resolvedAddresses = emptyList(),
)
endpointAttempts?.add(
TunEndpointAttempt(
endpoint = endpoint.url,
familyHint = endpoint.familyHint.name,
status = PublicIpProbeStatus.FAILED,
error = error.renderMessage(),
),
)
lastError = error.renderMessage()
lastDiagnostics = diagnostics
continue
}
val response = NativeCurlBridge.execute(
NativeCurlRequest(
url = endpoint.url,
interfaceName = binding.interfaceName,
resolveRules = resolveConfig.resolveRules,
ipResolveMode = ipResolveModeFor(endpoint.familyHint),
timeoutMs = timeoutMs,
connectTimeoutMs = timeoutMs,
caBundlePath = NativeCurlBridge.caBundleInfo()?.absolutePath.orEmpty(),
debugVerbose = collectTrace,
),
)
val diagnostics = diagnosticsFor(
resolveStrategy = resolveConfig.strategy,
response = response,
resolvedAddresses = resolveConfig.resolvedAddresses,
)
lastDiagnostics = diagnostics
val ipResult = response.toIpResult(endpoint.url)
endpointAttempts?.add(
TunEndpointAttempt(
endpoint = endpoint.url,
familyHint = endpoint.familyHint.name,
status = if (ipResult.isSuccess) PublicIpProbeStatus.SUCCEEDED else PublicIpProbeStatus.FAILED,
ip = ipResult.getOrNull(),
error = ipResult.exceptionOrNull().renderMessage(),
),
)
if (ipResult.isSuccess) {
return PublicIpModeProbeResult(
mode = mode,
status = PublicIpProbeStatus.SUCCEEDED,
ip = ipResult.getOrNull(),
endpointAttempts = endpointAttempts.orEmpty(),
transportDiagnostics = diagnostics,
)
}
lastError = ipResult.exceptionOrNull().renderMessage()
}
return PublicIpModeProbeResult(
mode = mode,
status = PublicIpProbeStatus.FAILED,
error = lastError ?: "Native curl-compatible probe failed",
endpointAttempts = endpointAttempts.orEmpty(),
transportDiagnostics = lastDiagnostics,
)
}
private data class ResolveConfig(
val strategy: TunProbeResolveStrategy,
val resolveRules: List<NativeCurlResolveRule>,
val resolvedAddresses: List<String>,
)
private fun resolveConfigFor(
endpoint: IpEndpointSpec,
resolverConfig: DnsResolverConfig,
binding: com.notcvnt.rknhardering.network.ResolverBinding.OsDeviceBinding,
): ResolveConfig {
if (resolverConfig.mode == DnsResolverMode.SYSTEM) {
return ResolveConfig(
strategy = TunProbeResolveStrategy.NATIVE_DEFAULT,
resolveRules = emptyList(),
resolvedAddresses = emptyList(),
)
}
val host = URL(endpoint.url).host
val port = URL(endpoint.url).port.takeIf { it > 0 } ?: DEFAULT_PORT
val resolvedAddresses = ResolverNetworkStack.lookup(host, resolverConfig, binding)
.filter { address ->
when (endpoint.familyHint) {
IpEndpointFamilyHint.GENERIC -> true
IpEndpointFamilyHint.IPV4 -> address is Inet4Address
IpEndpointFamilyHint.IPV6 -> address is Inet6Address
}
}
.mapNotNull { it.hostAddress }
.distinct()
if (resolvedAddresses.isEmpty()) {
throw IOException("Resolver returned no addresses for $host")
}
return ResolveConfig(
strategy = TunProbeResolveStrategy.KOTLIN_INJECTED,
resolveRules = listOf(NativeCurlResolveRule(host = host, port = port, addresses = resolvedAddresses)),
resolvedAddresses = resolvedAddresses,
)
}
private fun diagnosticsFor(
resolveStrategy: TunProbeResolveStrategy,
response: NativeCurlResponse,
resolvedAddresses: List<String>,
): PublicIpTransportDiagnostics {
return PublicIpTransportDiagnostics(
engine = TunProbeEngine.NATIVE_LIBCURL,
resolveStrategy = resolveStrategy,
curlCode = response.curlCode,
httpCode = response.httpCode,
nativeLibraryLoaded = NativeCurlBridge.isLibraryLoaded(),
caBundleVersion = NativeCurlBridge.caBundleInfo()?.versionHash,
resolvedAddressesUsed = response.resolvedAddressesUsed.ifEmpty { resolvedAddresses },
)
}
private fun ipResolveModeFor(familyHint: IpEndpointFamilyHint): NativeCurlIpResolveMode {
return when (familyHint) {
IpEndpointFamilyHint.GENERIC -> NativeCurlIpResolveMode.WHATEVER
IpEndpointFamilyHint.IPV4 -> NativeCurlIpResolveMode.V4
IpEndpointFamilyHint.IPV6 -> NativeCurlIpResolveMode.V6
}
}
private fun resolveStrategyFor(config: DnsResolverConfig): TunProbeResolveStrategy {
return if (config.mode == DnsResolverMode.SYSTEM) {
TunProbeResolveStrategy.NATIVE_DEFAULT
} else {
TunProbeResolveStrategy.KOTLIN_INJECTED
}
}
private fun NativeCurlResponse.toIpResult(endpoint: String): Result<String> {
localError?.let { return Result.failure(IOException(it)) }
errorBuffer?.takeIf { it.isNotBlank() }?.let { return Result.failure(IOException(it)) }
val code = httpCode ?: 0
if (code !in 200..299) {
return Result.failure(IOException(PublicIpClient.formatHttpError(code, body)))
}
val ip = PublicIpClient.extractIp(body, endpoint)
?: return Result.failure(IOException("Response does not look like an IP: ${body.trim()}"))
return Result.success(ip)
}
private fun Throwable?.renderMessage(): String {
return this?.message
?: this?.javaClass?.simpleName
?: "unknown error"
}
}

View file

@ -2,6 +2,27 @@ package com.notcvnt.rknhardering.probe
import java.io.IOException
enum class TunProbeEngine(val debugName: String) {
LEGACY_JAVA("legacy-java"),
NATIVE_LIBCURL("native-libcurl"),
}
enum class TunProbeResolveStrategy(val debugName: String) {
LEGACY_STACK("legacy-stack"),
NATIVE_DEFAULT("native-default"),
KOTLIN_INJECTED("kotlin-injected"),
}
data class PublicIpTransportDiagnostics(
val engine: TunProbeEngine? = null,
val resolveStrategy: TunProbeResolveStrategy? = null,
val curlCode: Int? = null,
val httpCode: Int? = null,
val nativeLibraryLoaded: Boolean? = null,
val caBundleVersion: String? = null,
val resolvedAddressesUsed: List<String> = emptyList(),
)
enum class PublicIpProbeMode {
STRICT_SAME_PATH,
CURL_COMPATIBLE,
@ -19,6 +40,7 @@ data class PublicIpModeProbeResult(
val ip: String? = null,
val error: String? = null,
val endpointAttempts: List<TunEndpointAttempt> = emptyList(),
val transportDiagnostics: PublicIpTransportDiagnostics = PublicIpTransportDiagnostics(),
)
data class PublicIpNetworkComparison(
@ -52,6 +74,7 @@ data class PublicIpNetworkComparison(
ip = strict.ip,
error = strict.error,
endpointAttempts = strict.endpointAttempts,
transportDiagnostics = strict.transportDiagnostics,
),
curlCompatible = TunProbeAttemptDiagnostics(
mode = curlCompatible.mode,
@ -59,6 +82,7 @@ data class PublicIpNetworkComparison(
ip = curlCompatible.ip,
error = curlCompatible.error,
endpointAttempts = curlCompatible.endpointAttempts,
transportDiagnostics = curlCompatible.transportDiagnostics,
),
)
}

View file

@ -26,6 +26,7 @@ data class TunProbeAttemptDiagnostics(
val ip: String? = null,
val error: String? = null,
val endpointAttempts: List<TunEndpointAttempt> = emptyList(),
val transportDiagnostics: PublicIpTransportDiagnostics = PublicIpTransportDiagnostics(),
)
data class TunProbePathDiagnostics(

View file

@ -50,6 +50,7 @@ object UnderlyingNetworkProber {
debugEnabled: Boolean = false,
modeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
): ProbeResult = withContext(Dispatchers.IO) {
NativeCurlBridge.initIfNeeded(context)
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = cm.activeNetwork
val activeNetworkIsVpn = activeNetwork

View file

@ -87,9 +87,14 @@
<string name="main_card_call_transport_status_needs_review">نیاز به بررسی</string>
<string name="main_card_call_transport_status_unsupported">پشتیبانی نمی‌شود</string>
<string name="main_card_call_transport_status_error">خطا</string>
<string name="main_card_call_transport_status_baseline">پاسخ داد</string>
<string name="main_card_call_transport_path_active">شبکه فعال</string>
<string name="main_card_call_transport_path_underlying">شبکه زیرین</string>
<string name="main_card_call_transport_path_proxy">پروکسی محلی</string>
<string name="main_card_call_transport_reason_no_response">هیچ پاسخی از STUN دریافت نشد</string>
<string name="main_card_call_transport_reason_telegram_dc_unreachable">Telegram DC از طریق پروکسی SOCKS5 محلی قابل دسترس نیست</string>
<string name="main_card_call_transport_reason_release_only">ردیابی WhatsApp فقط در بیلدهای debug یا experimental در دسترس است</string>
<string name="main_card_call_transport_reason_targets_unavailable">اهداف این بررسی در دسترس نیستند</string>
<string name="main_card_location_signals">نشانه‌های مکان</string>
<string name="main_verdict_details">جزئیات</string>
@ -386,6 +391,9 @@
<string name="checker_tun_probe_status_succeeded">succeeded</string>
<string name="checker_tun_probe_status_failed">failed</string>
<string name="checker_tun_probe_status_skipped">skipped</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">کاوش فعال TUN: مسیر curl-compatible آی‌پی %1$s را برگرداند</string>
<string name="checker_bypass_tun_probe_dns_mismatch_curl_compatible">کاوش فعال TUN: strict same-path شکست خورد (%1$s)، اما مسیر curl-compatible موفق شد؛ احتمال mismatch در DNS-path زیاد است</string>
<string name="checker_bypass_curl_compatible_used">مقایسه split tunnel از fallback نوع curl-compatible برای این مسیرها استفاده کرد: %1$s</string>
<string name="checker_vpn_known_apps_none">برنامه‌های شناخته‌شده VPN/Proxy و ارائه‌دهندگان VpnService: موردی پیدا نشد</string>
<string name="checker_vpn_installed_app">برنامه نصب‌شده: %1$s (%2$s)</string>

View file

@ -87,9 +87,14 @@
<string name="main_card_call_transport_status_needs_review">需要检查</string>
<string name="main_card_call_transport_status_unsupported">不支持</string>
<string name="main_card_call_transport_status_error">错误</string>
<string name="main_card_call_transport_status_baseline">已响应</string>
<string name="main_card_call_transport_path_active">活跃网络</string>
<string name="main_card_call_transport_path_underlying">底层网络</string>
<string name="main_card_call_transport_path_proxy">本地代理</string>
<string name="main_card_call_transport_reason_no_response">未收到 STUN 响应</string>
<string name="main_card_call_transport_reason_telegram_dc_unreachable">无法通过本地 SOCKS5 代理访问 Telegram DC</string>
<string name="main_card_call_transport_reason_release_only">WhatsApp 跟踪仅在 debug 或 experimental 构建中可用</string>
<string name="main_card_call_transport_reason_targets_unavailable">此检查的目标不可用</string>
<string name="main_card_location_signals">位置迹象</string>
<string name="main_verdict_details">详情</string>
@ -364,9 +369,11 @@
<string name="checker_bypass_vpn_network_binding">VPN network binding应用同时访问到了默认非 VPN 路径和 VPN NetworkVPN IP%1$s默认 IP%2$s</string>
<string name="checker_bypass_tun_probe_success">TUN active probe通过 VPN Network 发起的请求返回了 IP %1$sTUN 是一个可路由的活动接口)</string>
<string name="checker_bypass_tun_probe_success_transport_only">TUN active probecurl-compatible transport-only 路径返回了 IP %1$sSO_BINDTODEVICE + system DNS</string>
<string name="checker_bypass_tun_probe_success_curl_compatible">TUN 活跃探测curl-compatible 路径返回了 IP %1$s</string>
<string name="checker_bypass_tun_probe_failure">TUN active probe通过 VPN Network 发起的请求不可用(接口被阻止或被 filtered</string>
<string name="checker_bypass_tun_probe_failure_reason">TUN 活跃探测:通过 VPN Network 发起的请求失败(%1$s</string>
<string name="checker_bypass_tun_probe_dns_mismatch">TUN 活跃探测strict same-path 失败(%1$s但 curl-compatible transport-only 路径成功;很可能存在 DNS path mismatch</string>
<string name="checker_bypass_tun_probe_dns_mismatch_curl_compatible">TUN 活跃探测strict same-path 失败(%1$s但 curl-compatible 路径成功;很可能存在 DNS path mismatch</string>
<string name="checker_bypass_default_non_vpn_ip">默认非 VPN IP%1$s</string>
<string name="checker_bypass_underlying_unreachable">Underlying network非 VPN 网络不可用full tunnel</string>
<string name="checker_bypass_underlying_unreachable_reason">底层网络:非 VPN 网络不可用(%1$s</string>
@ -375,6 +382,7 @@
<string name="checker_bypass_compare_family_mismatch">Split tunnel 对比返回了不同地址族的结果VPN IP%1$s非 VPN IP%2$s需要人工复核</string>
<string name="checker_bypass_compare_incomplete">Split tunnel 对比不完整VPN IP%1$s非 VPN IP%2$s需要人工复核</string>
<string name="checker_bypass_transport_only_used">Split tunnel 对比依赖 curl-compatible transport-only fallback 的路径:%1$s</string>
<string name="checker_bypass_curl_compatible_used">Split tunnel 对比依赖 curl-compatible fallback 的路径:%1$s</string>
<string name="checker_bypass_transport_only_vpn_path">VPN 路径</string>
<string name="checker_bypass_transport_only_underlying_path">underlying 路径</string>
<string name="checker_tun_probe_debug_summary">%1$s debugeffective mode %2$sselected mode %3$sstrict %4$scurl-compatible %5$sdnsPathMismatch %6$s</string>

View file

@ -5,11 +5,14 @@ import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.DnsResolverMode
import com.notcvnt.rknhardering.probe.PublicIpProbeMode
import com.notcvnt.rknhardering.probe.PublicIpProbeStatus
import com.notcvnt.rknhardering.probe.PublicIpTransportDiagnostics
import com.notcvnt.rknhardering.probe.TunEndpointAttempt
import com.notcvnt.rknhardering.probe.TunProbeAttemptDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeEngine
import com.notcvnt.rknhardering.probe.TunProbeModeOverride
import com.notcvnt.rknhardering.probe.TunProbePathDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeResolveStrategy
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ -47,6 +50,15 @@ class TunProbeDiagnosticsFormatterTest {
mode = PublicIpProbeMode.CURL_COMPATIBLE,
status = PublicIpProbeStatus.SUCCEEDED,
ip = "203.0.113.64",
transportDiagnostics = PublicIpTransportDiagnostics(
engine = TunProbeEngine.NATIVE_LIBCURL,
resolveStrategy = TunProbeResolveStrategy.KOTLIN_INJECTED,
curlCode = 0,
httpCode = 200,
nativeLibraryLoaded = true,
caBundleVersion = "cafebabe",
resolvedAddressesUsed = listOf("203.0.113.10"),
),
),
),
underlyingPath = TunProbePathDiagnostics(
@ -84,5 +96,9 @@ class TunProbeDiagnosticsFormatterTest {
assertFalse(report.contains("203.0.113.64"))
assertTrue(report.contains("192.168.1.55"))
assertTrue(report.contains("timestamp: 1970-01-01T"))
assertTrue(report.contains("curl-compatible.engine: native-libcurl"))
assertTrue(report.contains("curl-compatible.resolveStrategy: kotlin-injected"))
assertTrue(report.contains("curl-compatible.caBundleVersion: cafebabe"))
assertTrue(report.contains("curl-compatible.resolvedAddresses: 203.0.*.*"))
}
}

View file

@ -13,6 +13,8 @@ import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverBinding
import com.notcvnt.rknhardering.probe.LocalSocketListener
import com.notcvnt.rknhardering.probe.NativeCurlBridge
import com.notcvnt.rknhardering.probe.NativeCurlResponse
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.ProxyType
import com.notcvnt.rknhardering.probe.PublicIpClient
@ -37,6 +39,7 @@ class CallTransportCheckerTest {
fun tearDown() {
CallTransportChecker.dependenciesOverride = null
PublicIpClient.resetForTests()
NativeCurlBridge.resetForTests()
}
@Test
@ -298,10 +301,19 @@ class CallTransportCheckerTest {
observedBindings += binding
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("primary path failed"))
is ResolverBinding.OsDeviceBinding -> Result.success("203.0.113.10")
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
val observedInterfaces = mutableListOf<String>()
NativeCurlBridge.executeOverride = { request ->
observedInterfaces += request.interfaceName
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.10",
)
}
CallTransportChecker.dependenciesOverride = CallTransportChecker.Dependencies(
loadCatalog = { _, _ -> catalogWithTelegramTarget() },
loadPaths = {
@ -321,9 +333,7 @@ class CallTransportCheckerTest {
val telegram = results.first { it.service == CallTransportService.TELEGRAM }
assertEquals(CallTransportStatus.NEEDS_REVIEW, telegram.status)
assertTrue(observedBindings.any { it is ResolverBinding.AndroidNetworkBinding })
val fallbackBinding = observedBindings.last { it is ResolverBinding.OsDeviceBinding } as ResolverBinding.OsDeviceBinding
assertEquals("tun0", fallbackBinding.interfaceName)
assertEquals(ResolverBinding.DnsMode.SYSTEM, fallbackBinding.dnsMode)
assertEquals(listOf("tun0"), observedInterfaces)
}
@Test
@ -333,10 +343,19 @@ class CallTransportCheckerTest {
observedBindings += binding
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("primary path failed"))
is ResolverBinding.OsDeviceBinding -> Result.success("203.0.113.10")
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
val observedInterfaces = mutableListOf<String>()
NativeCurlBridge.executeOverride = { request ->
observedInterfaces += request.interfaceName
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.10",
)
}
CallTransportChecker.dependenciesOverride = CallTransportChecker.Dependencies(
loadCatalog = { _, _ -> catalogWithTelegramTarget() },
loadPaths = {
@ -355,9 +374,8 @@ class CallTransportCheckerTest {
val telegram = results.first { it.service == CallTransportService.TELEGRAM }
assertEquals(CallTransportStatus.NEEDS_REVIEW, telegram.status)
val fallbackBinding = observedBindings.last { it is ResolverBinding.OsDeviceBinding } as ResolverBinding.OsDeviceBinding
assertEquals("wlan0", fallbackBinding.interfaceName)
assertEquals(ResolverBinding.DnsMode.SYSTEM, fallbackBinding.dnsMode)
assertTrue(observedBindings.any { it is ResolverBinding.AndroidNetworkBinding })
assertEquals(listOf("wlan0"), observedInterfaces)
}
@Test

View file

@ -183,7 +183,7 @@ class VerdictEngineTest {
}
@Test
fun `call transport leak does not affect verdict`() {
fun `direct call transport leak elevates clean verdict to needs review`() {
val verdict = VerdictEngine.evaluate(
geoIp = category(),
directSigns = category(),
@ -207,7 +207,7 @@ class VerdictEngineTest {
bypassResult = bypass(),
)
assertEquals(Verdict.NOT_DETECTED, verdict)
assertEquals(Verdict.NEEDS_REVIEW, verdict)
}
@Test

View file

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

View file

@ -2,7 +2,9 @@ package com.notcvnt.rknhardering.probe
import android.net.Network
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import com.notcvnt.rknhardering.network.ResolverBinding
import okhttp3.Dns
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -11,6 +13,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.IOException
import java.net.InetAddress
@RunWith(RobolectricTestRunner::class)
class IfconfigClientTest {
@ -18,19 +21,30 @@ class IfconfigClientTest {
@After
fun tearDown() {
PublicIpClient.resetForTests()
NativeCurlBridge.resetForTests()
ResolverNetworkStack.dnsFactoryOverride = null
ResolverNetworkStack.resetForTests()
}
@Test
fun `fetch ip via network keeps primary then fallback order`() {
val observedBindings = mutableListOf<ResolverBinding?>()
val events = mutableListOf<String>()
PublicIpClient.fetchIpOverride = { _, _, _, _, binding ->
observedBindings += binding
events += "strict:${binding?.javaClass?.simpleName}"
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("primary path failed"))
is ResolverBinding.OsDeviceBinding -> Result.success("203.0.113.20")
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
NativeCurlBridge.executeOverride = { request ->
events += "native:${request.interfaceName}"
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.20",
)
}
val result = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetwork(
@ -45,13 +59,8 @@ class IfconfigClientTest {
assertTrue(result.isSuccess)
assertEquals("203.0.113.20", result.getOrNull())
assertTrue(observedBindings.first() is ResolverBinding.AndroidNetworkBinding)
val fallbackIndex = observedBindings.indexOfFirst { it is ResolverBinding.OsDeviceBinding }
assertTrue(fallbackIndex > 0)
assertTrue(observedBindings.take(fallbackIndex).all { it is ResolverBinding.AndroidNetworkBinding })
val fallbackBinding = observedBindings[fallbackIndex] as ResolverBinding.OsDeviceBinding
assertEquals("tun0", fallbackBinding.interfaceName)
assertEquals(ResolverBinding.DnsMode.SYSTEM, fallbackBinding.dnsMode)
assertEquals("strict:AndroidNetworkBinding", events.first())
assertTrue(events.any { it == "native:tun0" })
}
@Test
@ -59,10 +68,13 @@ class IfconfigClientTest {
PublicIpClient.fetchIpOverride = { _, _, _, _, binding ->
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("primary path failed"))
is ResolverBinding.OsDeviceBinding -> Result.failure(IOException("device path failed"))
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
NativeCurlBridge.executeOverride = {
NativeCurlResponse(localError = "device path failed")
}
val result = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetwork(
@ -84,15 +96,20 @@ class IfconfigClientTest {
@Test
fun `network comparison marks dns path mismatch when curl compatible succeeds after strict failure`() {
val observedBindings = mutableListOf<ResolverBinding?>()
PublicIpClient.fetchIpOverride = { _, _, _, _, binding ->
observedBindings += binding
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("strict failed"))
is ResolverBinding.OsDeviceBinding -> Result.success("203.0.113.21")
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
NativeCurlBridge.executeOverride = {
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.21",
)
}
val comparison = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetworkComparison(
@ -113,21 +130,27 @@ class IfconfigClientTest {
assertTrue(comparison.dnsPathMismatch)
assertFalse(comparison.strict.endpointAttempts.isEmpty())
assertFalse(comparison.curlCompatible.endpointAttempts.isEmpty())
assertTrue(observedBindings.any { it is ResolverBinding.AndroidNetworkBinding })
assertTrue(observedBindings.any { it is ResolverBinding.OsDeviceBinding })
assertEquals(TunProbeEngine.NATIVE_LIBCURL, comparison.curlCompatible.transportDiagnostics.engine)
}
@Test
fun `network comparison still runs curl compatible branch after strict success`() {
val observedBindings = mutableListOf<ResolverBinding?>()
var nativeCalls = 0
PublicIpClient.fetchIpOverride = { _, _, _, _, binding ->
observedBindings += binding
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.success("198.51.100.10")
is ResolverBinding.OsDeviceBinding -> Result.success("203.0.113.22")
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
NativeCurlBridge.executeOverride = {
nativeCalls++
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.22",
)
}
val comparison = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetworkComparison(
@ -144,8 +167,7 @@ class IfconfigClientTest {
assertEquals(PublicIpProbeMode.STRICT_SAME_PATH, comparison.selectedMode)
assertEquals("198.51.100.10", comparison.selectedIp)
assertEquals(PublicIpProbeStatus.SUCCEEDED, comparison.curlCompatible.status)
assertTrue(observedBindings.any { it is ResolverBinding.AndroidNetworkBinding })
assertTrue(observedBindings.any { it is ResolverBinding.OsDeviceBinding })
assertEquals(1, nativeCalls)
}
@Test
@ -180,15 +202,22 @@ class IfconfigClientTest {
@Test
fun `curl compatible override skips strict branch`() {
val observedBindings = mutableListOf<ResolverBinding?>()
val strictCalls = mutableListOf<ResolverBinding?>()
PublicIpClient.fetchIpOverride = { _, _, _, _, binding ->
observedBindings += binding
strictCalls += binding
when (binding) {
is ResolverBinding.OsDeviceBinding -> Result.success("203.0.113.32")
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("strict should not run"))
null -> Result.failure(IOException("unexpected unbound path"))
else -> Result.failure(IOException("unexpected binding"))
}
}
NativeCurlBridge.executeOverride = {
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.32",
)
}
val comparison = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetworkComparison(
@ -205,7 +234,7 @@ class IfconfigClientTest {
assertEquals(PublicIpProbeMode.CURL_COMPATIBLE, comparison.selectedMode)
assertEquals(PublicIpProbeStatus.SKIPPED, comparison.strict.status)
assertEquals("Disabled by override", comparison.strict.error)
assertTrue(observedBindings.all { it is ResolverBinding.OsDeviceBinding })
assertTrue(strictCalls.isEmpty())
}
@Test
@ -254,6 +283,58 @@ class IfconfigClientTest {
assertFalse(comparison.dnsPathMismatch)
}
@Test
fun `curl compatible uses injected resolve for direct resolver`() {
var observedBinding: ResolverBinding? = null
PublicIpClient.fetchIpOverride = { _, _, _, _, binding ->
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> Result.failure(IOException("strict failed"))
else -> Result.failure(IOException("unexpected binding"))
}
}
ResolverNetworkStack.dnsFactoryOverride = { _, binding ->
observedBinding = binding
object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (hostname == "ifconfig.me") {
return listOf(InetAddress.getByName("93.184.216.34"))
}
throw java.net.UnknownHostException(hostname)
}
}
}
NativeCurlBridge.executeOverride = { request ->
assertEquals(1, request.resolveRules.size)
assertEquals("ifconfig.me", request.resolveRules.single().host)
assertTrue(request.resolveRules.single().addresses.isNotEmpty())
NativeCurlResponse(
curlCode = 0,
httpCode = 200,
body = "203.0.113.45",
resolvedAddressesUsed = request.resolveRules.single().addresses,
)
}
val comparison = kotlinx.coroutines.runBlocking {
IfconfigClient.fetchIpViaNetworkComparison(
primaryBinding = ResolverBinding.AndroidNetworkBinding(newNetwork(210)),
fallbackBinding = ResolverBinding.OsDeviceBinding(
interfaceName = "tun0",
dnsMode = ResolverBinding.DnsMode.SYSTEM,
),
resolverConfig = DnsResolverConfig(
mode = com.notcvnt.rknhardering.network.DnsResolverMode.DIRECT,
customDirectServers = listOf("1.1.1.1"),
),
)
}
assertEquals(PublicIpProbeMode.CURL_COMPATIBLE, comparison.selectedMode)
assertEquals(TunProbeResolveStrategy.KOTLIN_INJECTED, comparison.curlCompatible.transportDiagnostics.resolveStrategy)
assertTrue(observedBinding is ResolverBinding.OsDeviceBinding)
assertEquals("tun0", (observedBinding as ResolverBinding.OsDeviceBinding).interfaceName)
}
@Test
fun `fetch direct ip prefers generic or ipv4 error over trailing ipv6-only failure`() {
PublicIpClient.fetchIpOverride = { endpoint, _, _, _, _ ->

10
scripts/update-cacert.sh Normal file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ASSET_DIR="$ROOT_DIR/app/src/main/assets/native-curl"
mkdir -p "$ASSET_DIR"
curl -L --fail -o "$ASSET_DIR/cacert.pem" https://curl.se/ca/cacert.pem
sha256sum "$ASSET_DIR/cacert.pem" | awk '{print $1}' > "$ASSET_DIR/cacert.pem.sha256"