mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-09 17:19:25 +00:00
fix: curl probing метод теперь должен работать как задуманно
Closes #31
This commit is contained in:
parent
2e5f64c7e4
commit
11849f2c1b
32 changed files with 4732 additions and 43 deletions
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
|
|
@ -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
1
.idea/misc.xml
generated
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
3529
app/src/main/assets/native-curl/cacert.pem
Normal file
3529
app/src/main/assets/native-curl/cacert.pem
Normal file
File diff suppressed because it is too large
Load diff
1
app/src/main/assets/native-curl/cacert.pem.sha256
Normal file
1
app/src/main/assets/native-curl/cacert.pem.sha256
Normal file
|
|
@ -0,0 +1 @@
|
|||
b6e66569cc3d438dd5abe514d0df50005d570bfc96c14dca8f768d020cb96171
|
||||
77
app/src/main/cpp/CMakeLists.txt
Normal file
77
app/src/main/cpp/CMakeLists.txt
Normal 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
|
||||
)
|
||||
234
app/src/main/cpp/native_curl_probe.cpp
Normal file
234
app/src/main/cpp/native_curl_probe.cpp
Normal 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);
|
||||
}
|
||||
|
||||
19
app/src/main/cpp/native_curl_probe.h
Normal file
19
app/src/main/cpp/native_curl_probe.h
Normal 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
|
||||
|
||||
BIN
app/src/main/cpp/third_party/curl-8.19.0.zip
vendored
Normal file
BIN
app/src/main/cpp/third_party/curl-8.19.0.zip
vendored
Normal file
Binary file not shown.
BIN
app/src/main/cpp/third_party/mbedtls-3.6.3.zip
vendored
Normal file
BIN
app/src/main/cpp/third_party/mbedtls-3.6.3.zip
vendored
Normal file
Binary file not shown.
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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?>
|
||||
}
|
||||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 Network(VPN IP:%1$s,默认 IP:%2$s)</string>
|
||||
<string name="checker_bypass_tun_probe_success">TUN active probe:通过 VPN Network 发起的请求返回了 IP %1$s(TUN 是一个可路由的活动接口)</string>
|
||||
<string name="checker_bypass_tun_probe_success_transport_only">TUN active probe:curl-compatible transport-only 路径返回了 IP %1$s(SO_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 debug:effective mode %2$s,selected mode %3$s,strict %4$s,curl-compatible %5$s,dnsPathMismatch %6$s</string>
|
||||
|
|
|
|||
|
|
@ -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.*.*"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
10
scripts/update-cacert.sh
Normal 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"
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue