diff --git a/.gitignore b/.gitignore index acd2417..bf7360b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ release.jks !CONTRIBUTING.md !app/src/test/resources/export/golden/*.md /.codex -.marketplace-keys/ \ No newline at end of file +.marketplace-keys/ +app-release/ \ No newline at end of file diff --git a/README.md b/README.md index 5b96719..3103ea0 100644 --- a/README.md +++ b/README.md @@ -528,7 +528,7 @@ TLS-шаг использует trust-all X.509 manager, потому что ц #### 12.2 Host-route /32 эвристика -В таблице маршрутов (`NETLINK`) ищется не-default маршрут с префиксом `/32` (IPv4) или `/128` (IPv6) к публичному маршрутизируемому адресу через физический интерфейс (`wlan0`, `rmnet`, `eth0`). Это классический хост-маршрут VPN-клиента к IP своего сервера в обход туннеля — утечка реального IP VPN-сервера. Итог: `detected = true` (`EvidenceSource.NATIVE_ROUTE`). +В таблице маршрутов (`NETLINK`) ищется не-default маршрут с префиксом `/32` (IPv4) или `/128` (IPv6) к публичному маршрутизируемому адресу через физический интерфейс (`wlan0`, `rmnet`, `eth0`). Это классический хост-маршрут VPN-клиента к IP своего сервера в обход туннеля — утечка реального IP VPN-сервера. Записи таблицы `local` и созданные ядром link-маршруты на модемных интерфейсах (`rmnet*`, `ccmni*`, `pdp*`, `seth*`) исключаются: операторы используют их для служебной адресации независимо от VPN. Статические глобальные host-route на этих интерфейсах остаются кандидатами. Итог: `detected = true` (`EvidenceSource.NATIVE_ROUTE`). #### 12.3 Детектор эмулятора @@ -546,6 +546,110 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne Любой из сигналов даёт `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), **никогда** `detected`. +#### 12.5 VPN-сигналы (`evaluateVpnSignals`) + +Комплексная проверка VPN через нативные JNI-вызовы. Все проверки работают на **нерутованных** устройствах — при отсутствии прав (SELinux/capabilities) проверка помечается как `unavailable` и не крашится. + +**Свойства и файлы (`nativeDetectVpnProperties`):** + +| Проверка | Что ищем | Источник | +|----------|----------|----------| +| DNS-свойства | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` | +| VPN-свойства | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` | +| vpnhide файлы | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` и др. | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| Hook-свойства | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` | + +Высокая уверенность: `vpn_prop`, `vpnhide`, `hook_prop`. Средняя: остальные. + +**Утечки через /proc (`nativeDetectVpnLeaks`):** + +| Проверка | Что ищем | Источник | +|----------|----------|----------| +| TCP VPN-порты | Соединения на портах 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` | +| UDP VPN-порты | Сокеты на портах 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` | +| if_inet6 | Интерфейсы tun/wg/ppp/tap в `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| Route VPN | Маршруты через tun/wg/ppp/tap | `/proc/net/route` | +| FIB trie | `/32 host` записи (не LOCAL) — хост-маршруты VPN | `/proc/net/fib_trie` | + +Высокая уверенность: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`. + +**Продвинутые проверки (`nativeDetectVpnAdvanced`):** + +| Проверка | Что ищем | Источник | +|----------|----------|----------| +| ARP VPN | Записи на tun/wg/ppp интерфейсах | `/proc/net/arp` | +| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` и др. | +| ESTABLISHED VPN | Соединения к приватным IP на VPN-портах | `/proc/net/tcp` | + +**Нетрадиционные syscall-проверки (`nativeDetectVpnSyscalls`):** + +Проверки через прямые netlink-запросы и пробные соединения. При отсутствии прав возвращают `unavailable`: + +| Проверка | Метод | Что обнаруживает | +|----------|-------|-------------------| +| RTM_GETRULE | Netlink dump роутинг-правил | VPN policy routing rules | +| RTM_GETQDISC | Netlink dump queueing discipline | VPN qdisc туннели | +| RTM_GETNEIGH | Netlink dump таблицы соседей | Скрытые MAC-адреса (нулевые LLADDR) | +| TCP_INFO MSS | Connect к 8.8.8.8:443, чтение `snd_mss` | Снижение MSS (признак туннеля) | +| SO_BINDTODEVICE | Пробный bind на несуществующий интерфейс | Перехват setsockopt VPN-хуком | +| Loopback port bind | Попытка занять порты 51820/1194/443/8443 | Конфликты портов (VPN listener) | +| BPF OBJ GET | Попытка открыть `/sys/fs/bpf/` карты | Доступ к BPF-картам netd | +| IP_RECVERR | Пробный setsockopt IP_RECVERR | Перехват IP_RECVERR VPN-хуком | + +Высокая уверенность: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`. + +#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`) + + +Новые детекты вынесены в отдельную секцию внутри категории Native и сгруппированы по 4 подкатегориям. Данные приходят из нового JNI-метода `nativeDetectVpnDetector(cancellationSignal)`, строки имеют префикс `vdet|`. + +**Прямые признаки (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `sysfs_vpn_leak` | Утечка tun/wg/ppp/xfrm через sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | VPN-интерфейсы в списке `getifaddrs()` | `getifaddrs()` | +| `sysclassnet_vpn` | VPN-интерфейсы в `/sys/class/net` | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | VPN-интерфейсы через netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | VPN-интерфейсы в `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | VPN-маршруты в `/proc/net/ipv6_route` | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | VPN-трафик (RX/TX) в `/proc/net/dev` | `/proc/net/dev` | +| `ifindexname_vpn` | VPN-интерфейсы через `if_indextoname()` | `if_indextoname()` перебор ifindex | +| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100–200, oif=tun) | Netlink `RTM_GETRULE` dump | + +**Сетевые признаки (Network signs) — `EvidenceSource.NATIVE_SOCKET`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `fib_trie_denied` | `/proc/net/fib_trie` недоступен (SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink запрещён (SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` к tun + подтверждение `getsockopt` | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` возвращает приватный VPN-IP | `getsockname()` на UDP-сокете | +| `udp_port_conflict_physical` | Конфликт UDP-портов (500/4500/1194/1701/51820) на физическом IP | `bind()` на физический IP | +| `route_count` | Число маршрутов и уникальных интерфейсов | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | Расхождение bind-probe vs RTM_GETLINK по числу iface | `if_indextoname()` vs `RTM_GETLINK` | + +**Косвенные признаки (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | Успех/неудача отправки 1500 байт по UDP | `sendto()` 1500 байт | +| `normal_pmtu` | Path MTU основного физического интерфейса | `fetchMtu()` через `getifaddrs()` | +| `timing_oracle` | ARM CNTVCT циклы для `sendto()` (мин/макс/сред) | `mrs cntvct_el0` (aarch64) | +| `backpressure` | Пропускная способность при 50000 UDP-пакетах с отменой скана | Неблокирующий `sendto()` burst + проверка cancellation каждые 64 пакета | +| `gso_failed` / `gso_send_failed` / `gso_ok` | Диагностика поддержки UDP GSO; результат не является признаком VPN | `UDP_SEGMENT=1200` + отправка 4800 байт | +| `hw_timestamp` | Аппаратный таймстампинг (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**Пробы окружения (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:** + +| Проверка (kind) | Что ищем | Источник | +|-----------------|----------|----------| +| `traceroute_denied` | Traceroute-проба (TTL=1 UDP) заблокирована | `setsockopt(IP_TTL=1)` + `sendto()` | + +Высокая уверенность (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Сырые измерения и GSO-результаты информационные; остальные аномалии → `needsReview`. + --- ## Вердикт (`VerdictEngine`) @@ -571,7 +675,7 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne - `geoHit` = `GeoIP.outsideRu == true` (кроме роуминга) - `directHit` = detected-evidence из `DIRECT_NETWORK_CAPABILITIES` или `SYSTEM_PROXY` -- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` +- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` либо high-confidence `NATIVE_SOCKET` | Geo | Direct | Indirect | Вердикт | |-----|--------|----------|---------| @@ -612,6 +716,12 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne ./gradlew assembleDebug ``` +## Режимы подробности результатов + +В «Настройки → Внешний вид» доступны три режима. «Простой» показывает понятные итоги без низкоуровневых строк, «Обычный» сохраняет стандартное представление, а «Расширенный» добавляет свёрнутые технические данные к карточкам. Выбор фиксируется при запуске проверки, поэтому изменение настройки применяется только к следующему запуску и не меняет уже выполняющуюся или завершённую проверку. Режим не влияет на проверки, вердикт, таймауты или сетевое поведение. + +Технический снимок создаётся только для проверки, запущенной в расширенном режиме. Он хранится только в памяти процесса, очищается при отмене или новой проверке и не добавляется в JSON/Markdown-экспорт. Одна запись ограничена 64 КиБ, весь запуск — 512 КиБ; интерфейс явно отмечает обрезанные записи. Заголовки авторизации и cookie, пароли, токены, UUID, ключи, URI userinfo и чувствительные query-параметры удаляются до сохранения. В приватном режиме дополнительно маскируются все IPv4/IPv6-адреса. Полные ответы Clash `/configs`, `/connections`, `/proxies`, Xray UUID/public keys, BSSID и идентификаторы сот не сохраняются. + --- ## Благодарности diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44a189c..d50568a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,8 +29,8 @@ android { applicationId = "com.notcvnt.rknhardering" minSdk = 26 targetSdk = 36 - versionCode = 20900 - versionName = "2.9.0" + versionCode = 21000 + versionName = "2.10.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN") diff --git a/app/src/main/cpp/native_curl_probe.cpp b/app/src/main/cpp/native_curl_probe.cpp index ad31e63..7a0f780 100644 --- a/app/src/main/cpp/native_curl_probe.cpp +++ b/app/src/main/cpp/native_curl_probe.cpp @@ -166,7 +166,7 @@ int ProgressCallback( curl_off_t /* dlnow */, curl_off_t /* ultotal */, curl_off_t /* ulnow */) { - auto* state = static_cast(clientp); + const auto* state = static_cast(clientp); return state != nullptr && state->cancelled.load() ? 1 : 0; } diff --git a/app/src/main/cpp/native_signs_probe.cpp b/app/src/main/cpp/native_signs_probe.cpp index d811fc5..995c4bd 100644 --- a/app/src/main/cpp/native_signs_probe.cpp +++ b/app/src/main/cpp/native_signs_probe.cpp @@ -12,25 +12,37 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include #include +#include +#include #include +#include +#include #include #include #include #include #include +#include +#include +#include +#include #include #include +#include namespace { @@ -254,7 +266,8 @@ jobjectArray nativeReadSelfMapsSummary(JNIEnv *env, jclass /*clazz*/) { } if (rwx) { - unsigned long long start = 0, end = 0; + unsigned long long start = 0; + unsigned long long end = 0; if (std::sscanf(line, "%llx-%llx", &start, &end) == 2 && end > start) { unsigned long long size = end - start; if (size >= (256ULL * 1024ULL)) { @@ -347,8 +360,7 @@ int ipv6PrefixLen(const sockaddr *sa) { if (sa == nullptr || sa->sa_family != AF_INET6) return -1; const auto *in6 = reinterpret_cast(sa); int bits = 0; - for (int i = 0; i < 16; ++i) { - unsigned char b = in6->sin6_addr.s6_addr[i]; + for (unsigned char b : in6->sin6_addr.s6_addr) { if (b == 0xff) { bits += 8; continue; } for (int k = 7; k >= 0; --k) { if (b & (1u << k)) ++bits; else return bits; @@ -539,7 +551,7 @@ std::vector netlinkRouteDump(int family) { nh = NLMSG_NEXT(nh, len)) { if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } if (nh->nlmsg_type == NLMSG_ERROR) { - auto *err = reinterpret_cast(NLMSG_DATA(nh)); + const auto *err = reinterpret_cast(NLMSG_DATA(nh)); out.push_back(std::string("error|nlmsg|errno=") + std::to_string(-err->error)); done = true; break; @@ -550,7 +562,10 @@ std::vector netlinkRouteDump(int family) { int rtaLen = nh->nlmsg_len - NLMSG_LENGTH(sizeof(*rtm)); auto *attr = RTM_RTA(rtm); - std::string dst, gw, src, prefSrc; + std::string dst; + std::string gw; + std::string src; + std::string prefSrc; int oif = 0; unsigned int priority = 0; char iface[IF_NAMESIZE] = {0}; @@ -703,14 +718,14 @@ std::vector netlinkInetDiag(int family, int protocol) { nh = NLMSG_NEXT(nh, len)) { if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } if (nh->nlmsg_type == NLMSG_ERROR) { - auto *err = reinterpret_cast(NLMSG_DATA(nh)); + const auto *err = reinterpret_cast(NLMSG_DATA(nh)); out.push_back(std::string("error|nlmsg|errno=") + std::to_string(-err->error)); done = true; break; } if (nh->nlmsg_type != SOCK_DIAG_BY_FAMILY) continue; - auto *diag = reinterpret_cast(NLMSG_DATA(nh)); + const auto *diag = reinterpret_cast(NLMSG_DATA(nh)); char src[INET6_ADDRSTRLEN] = {0}; char dst[INET6_ADDRSTRLEN] = {0}; int af = diag->idiag_family; @@ -783,7 +798,6 @@ jobjectArray nativeLibraryIntegrity(JNIEnv *env, jclass /*clazz*/) { jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { std::vector findings; - // 1. Check for su binary in common paths static const char *const kSuPaths[] = { "/system/bin/su", "/system/xbin/su", @@ -806,7 +820,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } } - // 2. Check root-related system properties struct PropCheck { const char *prop; const char *suspiciousValue; @@ -831,7 +844,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } } - // 3. Check for root management app artifacts static const char *const kRootMgmtPaths[] = { "/data/adb/magisk", "/data/adb/modules", @@ -852,17 +864,14 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } } - // 4. Check /system writability if (access("/system", W_OK) == 0) { findings.emplace_back("system_rw|/system is writable"); } - // 5. Check /proc/self/mounts for suspicious mount entries FILE *mounts = std::fopen("/proc/self/mounts", "re"); if (mounts != nullptr) { char line[1024]; while (std::fgets(line, sizeof(line), mounts) != nullptr) { - // Magisk tmpfs overlays on /system, /vendor, /product if (std::strstr(line, "magisk") != nullptr || std::strstr(line, "core-only") != nullptr) { std::string raw(line); @@ -871,7 +880,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { f.append(raw); findings.push_back(f); } - // Overlayfs on /system suggests rootfs modifications if (std::strstr(line, "overlay") != nullptr && (std::strstr(line, "/system") != nullptr || std::strstr(line, "/vendor") != nullptr)) { @@ -885,7 +893,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { std::fclose(mounts); } - // 6. Check SELinux enforcement FILE *selinux = std::fopen("/sys/fs/selinux/enforce", "re"); if (selinux != nullptr) { int enforce = -1; @@ -894,11 +901,9 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { } std::fclose(selinux); } else { - // SELinux filesystem not accessible — might be disabled entirely findings.emplace_back("selinux|absent"); } - // 7. Check current process UID/GID uid_t uid = getuid(); uid_t euid = geteuid(); gid_t gid = getgid(); @@ -910,7 +915,6 @@ jobjectArray nativeDetectRoot(JNIEnv *env, jclass /*clazz*/) { findings.push_back(f); } - // 8. Check for Magisk-specific properties static const char *const kMagiskProps[] = { "init.svc.magisk_daemon", "init.svc.magisk_pfs", @@ -945,7 +949,6 @@ bool fileContains(const char *path, const char *needle) { jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { std::vector findings; - // 1. QEMU system properties static const char *const kQemuProps[] = { "ro.kernel.qemu", "ro.kernel.qemu.gles", @@ -963,7 +966,6 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { } } - // 2. Goldfish/ranchu hardware static const char *const kHwProps[] = { "ro.hardware", "ro.product.board", @@ -982,7 +984,6 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { } } - // 3. Emulator pipe devices (genyd => Genymotion) static const char *const kPipePaths[] = { "/dev/qemu_pipe", "/dev/socket/qemud", @@ -997,12 +998,10 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { } } - // 4. Goldfish TTY driver if (fileContains("/proc/tty/drivers", "goldfish")) { findings.emplace_back("qemu_driver|/proc/tty/drivers:goldfish"); } - // 5. BlueStacks artifacts static const char *const kBlueStacksPaths[] = { "/system/lib/libbstfolder_jni.so", "/data/bluestacks.prop", @@ -1018,6 +1017,1227 @@ jobjectArray nativeDetectEmulator(JNIEnv *env, jclass /*clazz*/) { return toStringArray(env, findings); } +} + +static void detectVpnPropertiesAll(std::vector &findings); +static void detectVpnFiles(std::vector &findings); +static void detectVpnInterfaces(std::vector &findings); +static void detectVpnRoutes(std::vector &findings); +static void detectArpNeighbors(std::vector &findings); +static void detectSysctl(std::vector &findings); +static void detectEstablishedVpn(std::vector &findings); +static void detectQdisc(std::vector &findings); +static void detectMssAnomaly(std::vector &findings); +static void detectBpfMaps(std::vector &findings); +static void detectLoopbackConflicts(std::vector &findings); +static void detectSocketLeaks(std::vector &findings); +static void detectIpRecvErr(std::vector &findings); +static jobjectArray nativeDetectVpnDetector(JNIEnv *env, jclass /*clazz*/, jobject cancellationSignal); +static void detectSysfsLeak(std::vector &findings); +static void detectGetifaddrsVpn(std::vector &findings); +static void detectSysclassnetVpn(std::vector &findings); +static void detectRtmGetlinkVpn(std::vector &findings); +static void detectProcIfInet6(std::vector &findings); +static void detectProcIpv6RouteVpn(std::vector &findings); +static void detectProcNetDevVpn(std::vector &findings); +static void detectFibTrieAccess(std::vector &findings); +static void detectSetsockoptBindToDevice(std::vector &findings); +static void detectInetDiagAccess(std::vector &findings); +static void detectGetsocknameLeak(std::vector &findings); +static void detectVpnPolicyRules(std::vector &findings); +static void detectRouteCount(std::vector &findings); +static void detectUdpPortConflictPhysicalIp(std::vector &findings); +static void detectTrimOracle(std::vector &findings); +static void detectIfindexnameVpn(std::vector &findings); +static void detectPmtuMssCombined(std::vector &findings); +static void detectUdpPmtu(std::vector &findings); +static void detectNormalPmtu(std::vector &findings); +static void detectTraceroute(std::vector &findings); +static void detectTimingOracle(std::vector &findings); +static void detectBackpressure( + JNIEnv *env, + jobject cancellationSignal, + jmethodID isCancelledMethod, + std::vector &findings +); +static void detectGsoLargeSend(std::vector &findings); +static void detectHwTimestamp(std::vector &findings); + +jobjectArray nativeDetectVpnProperties(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectVpnPropertiesAll(findings); + detectVpnFiles(findings); + + return toStringArray(env, findings); +} + +jobjectArray nativeDetectVpnLeaks(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectVpnInterfaces(findings); + detectVpnRoutes(findings); + detectArpNeighbors(findings); + + return toStringArray(env, findings); +} + +jobjectArray nativeDetectVpnAdvanced(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectSysctl(findings); + detectEstablishedVpn(findings); + detectQdisc(findings); + detectMssAnomaly(findings); + detectBpfMaps(findings); + + return toStringArray(env, findings); +} + +jobjectArray nativeDetectVpnSyscalls(JNIEnv *env, jclass /*clazz*/) { + std::vector findings; + + detectLoopbackConflicts(findings); + detectSocketLeaks(findings); + detectIpRecvErr(findings); + + return toStringArray(env, findings); +} + +static void detectVpnPropertiesAll(std::vector &findings) { + const std::vector vpnProps = { + "net.vpn.dns1", "net.vpn.dns2", + "dhcp.tun0.dns1", "dhcp.tun0.dns2", + "net.interfaces.default.type", "net.interfaces.default.name", + "net.vpn.dns3", "net.vpn.dns4", + }; + for (const auto &prop : vpnProps) { + char value[PROP_VALUE_MAX] = {0}; + if (__system_property_get(prop.c_str(), value) > 0 && value[0] != '\0') { + findings.emplace_back("vpn_prop|" + prop + "=" + value); + } + } + char devType[PROP_VALUE_MAX] = {0}; + if (__system_property_get("net.interfaces.default.type", devType) > 0) { + std::string lower = devType; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + if (lower.find("tun") != std::string::npos || lower.find("vpn") != std::string::npos) { + findings.emplace_back("vpn_prop|net.interfaces.default.type contains tun/vpn"); + } + } +} + +static void detectVpnFiles(std::vector &findings) { + const std::vector> pathGroups = { + {"vpnhide", "/data/local/tmp/vpnhide"}, + {"vpnhide", "/data/data/com.vpnhide"}, + {"vpnhide", "/data/local/tmp/.vpnhide"}, + {"vpnhide", "/data/data/com.vpn.hide"}, + {"lsposed", "/data/adb/lspd"}, + {"lsposed", "/data/adb/modules/lsposed"}, + {"lsposed", "/data/misc/lspd"}, + {"lsposed", "/data/data/org.lsposed.manager"}, + {"zygisk", "/data/adb/modules/zygisk"}, + {"zygisk", "/data/adb/zygisk"}, + {"zygisk", "/data/local/tmp/zygisk"}, + }; + for (const auto &[prefix, path] : pathGroups) { + struct stat st; + if (stat(path.c_str(), &st) == 0) { + findings.emplace_back(prefix + "|" + path); + } + } + const std::vector hookPropNames = { + "persist.sys.lspd.hook", "ro.magisk.version", + "init.svc.zygote_restart", + }; + for (const auto &prop : hookPropNames) { + char value[PROP_VALUE_MAX] = {0}; + if (__system_property_get(prop.c_str(), value) > 0 && value[0] != '\0') { + findings.emplace_back("hook_prop|" + prop + "=" + value); + } + } +} + +static bool ifaceExists(const char *name) { + struct ifreq ifr{}; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return false; + snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", name); + bool exists = (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0); + close(fd); + return exists; +} + +static void detectVpnInterfaces(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "utun1", "wg0", "wg1", "ppp0", "xfrm0"}; + for (const auto &name : tunNames) { + if (!ifaceExists(name.c_str())) continue; + struct ifreq ifr{}; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) continue; + snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", name.c_str()); + if (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0) { + findings.emplace_back("inet6_vpn_iface|" + name + "|flags=0x" + std::to_string(ifr.ifr_flags)); + } + close(fd); + } +} + +static bool isVpnIface(const char *iface) { + static const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "ppp0", "wg0", "wg1"}; + for (const auto &v : vpnIfaces) { + if (v == iface) return true; + } + return false; +} + +static void detectVpnRoutes(std::vector &findings) { + std::ifstream f("/proc/net/route"); + if (!f.is_open()) return; + std::string line; + int vpnRouteCount = 0; + std::getline(f, line); + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string ifaceBuf; + unsigned long dest = 0; + unsigned long gateway = 0; + if (!(iss >> ifaceBuf >> std::hex >> dest >> gateway)) continue; + if (!isVpnIface(ifaceBuf.c_str())) continue; + vpnRouteCount++; + if (dest == 0 || dest == 0xFFFFFFFF) continue; + std::ostringstream oss; + oss << std::hex << std::setw(8) << std::setfill('0') << dest; + findings.emplace_back("vpn_policy_rules|iface=" + ifaceBuf + " dest=" + oss.str()); + } + if (vpnRouteCount > 0) { + findings.emplace_back("route_vpn_iface|vpn_routes=" + std::to_string(vpnRouteCount)); + } +} + +static void detectArpNeighbors(std::vector &findings) { + std::ifstream f("/proc/net/arp"); + if (!f.is_open()) return; + std::string line; + std::getline(f, line); + int totalEntries = 0; + int hiddenEntries = 0; + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string ip, hwType, flags, mac, mask, iface; + if (!(iss >> ip >> hwType >> flags >> mac >> mask >> iface)) continue; + totalEntries++; + if (mac == "00:00:00:00:00:00" || mac.find("ff:ff:ff:ff:ff:ff") != std::string::npos) { + hiddenEntries++; + } + } + if (hiddenEntries > 0) { + findings.emplace_back("hidden_mac_neighbors|hidden=" + std::to_string(hiddenEntries) + " total=" + std::to_string(totalEntries)); + } +} + +static void detectSysctl(std::vector &findings) { + auto readInt = [](const char *path) -> std::optional { + FILE *f = fopen(path, "r"); + if (!f) return std::nullopt; + int val = 0; + bool ok = (fscanf(f, "%d", &val) == 1); + fclose(f); + return ok ? std::optional(val) : std::nullopt; + }; + if (auto val = readInt("/proc/sys/net/ipv4/ip_forward"); val && *val != 0) { + findings.emplace_back("sysctl_forwarding|ip_forward=1"); + } + if (auto val = readInt("/proc/sys/net/ipv4/conf/all/rp_filter"); val && *val == 0) { + findings.emplace_back("sysctl_rp_filter|rp_filter=0"); + } +} + +static bool isVpnPort(int port) { + return port == 51820 || port == 1194 || port == 1195 || port == 443 || port == 8443; +} + +static void detectEstablishedVpn(std::vector &findings) { + std::ifstream f("/proc/net/tcp"); + if (!f.is_open()) return; + std::string line; + std::getline(f, line); + while (std::getline(f, line)) { + unsigned long localAddr = 0; + unsigned long remoteAddr = 0; + int localPort = 0; + int remotePort = 0; + int state = 0; + if (sscanf(line.c_str(), " %lx:%x %lx:%x %x", &localAddr, &localPort, &remoteAddr, &remotePort, &state) < 5) continue; + if (state != 1) continue; + if (!isVpnPort(remotePort)) continue; + findings.emplace_back("established_vpn|remote_port=" + std::to_string(remotePort)); + } +} + +static void detectQdisc(std::vector &findings) { + std::ifstream f("/proc/net/dev"); + if (!f.is_open()) return; + std::string line; + std::getline(f, line); + std::getline(f, line); + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string iface; + std::getline(iss >> std::ws, iface, ':'); + unsigned long rxBytes = 0; + unsigned long rxPackets = 0; + iss >> rxBytes >> rxPackets; + for (int i = 0; i < 6; ++i) { unsigned long tmp = 0; iss >> tmp; } + unsigned long txBytes = 0; + unsigned long txPackets = 0; + iss >> txBytes >> txPackets; + if (iface != "tun0" && iface != "wg0") continue; + findings.emplace_back("vpn_qdisc|iface=" + iface + " rx=" + std::to_string(rxBytes) + " tx=" + std::to_string(txBytes)); + } +} + +static void detectMssAnomaly(std::vector &findings) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return; + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(443); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { close(fd); return; } + struct tcp_info info{}; + socklen_t infoLen = sizeof(info); + if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &info, &infoLen) == 0 && info.tcpi_snd_mss > 0 && info.tcpi_snd_mss < 500) { + findings.emplace_back("tcp_mss_low|snd_mss=" + std::to_string(info.tcpi_snd_mss) + "|rcv_mss=" + std::to_string(info.tcpi_rcv_mss)); + } + close(fd); +} + +static void detectBpfMaps(std::vector &findings) { + const std::vector paths = { + "/sys/fs/bpf/map_netd_iface_index_name_map", + "/sys/fs/bpf/netd_shared/map_netd_iface_index_name_map", + "/sys/fs/bpf/netd_iface_index_name_map", + }; + for (const auto &path : paths) { + int fd = open(path.c_str(), O_RDONLY); + if (fd < 0) continue; + close(fd); + findings.emplace_back("bpf_map_accessible|" + path); + } +} + +static void detectIpRecvErr(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + int opt = 1; + if (setsockopt(fd, IPPROTO_IP, IP_RECVERR, &opt, sizeof(opt)) < 0) { + if (errno == EACCES || errno == EPERM) { + findings.emplace_back("unavailable|ip_recverr|denied"); + } else if (errno == ENOPROTOOPT) { + findings.emplace_back("unavailable|ip_recverr|not_supported"); + } + } + close(fd); +} + +// --- n08/n04/n30: sysfs VPN interface leak --- +static void detectSysfsLeak(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + const std::vector sysfsBases = {"/sys/class/net", "/sys/devices/virtual/net"}; + std::vector leaked; + for (const auto &base : sysfsBases) { + for (const auto &name : tunNames) { + std::string path = base + "/" + name; + struct stat st; + if (stat(path.c_str(), &st) == 0) { + leaked.emplace_back(base + ": leaked " + name); + } + } + } + const std::vector procSysBases = { + "/proc/sys/net/ipv4/conf", "/proc/sys/net/ipv6/conf", + "/proc/sys/net/ipv4/neigh", "/proc/sys/net/ipv6/neigh", + }; + for (const auto &base : procSysBases) { + for (const auto &name : tunNames) { + std::string path = base + "/" + name; + struct stat st; + if (stat(path.c_str(), &st) == 0) { + leaked.emplace_back(base + ": leaked " + name); + } + } + } + if (!leaked.empty()) { + std::string r = "sysfs_vpn_leak|"; + for (size_t i = 0; i < leaked.size(); ++i) { + if (i > 0) r += ", "; + r += leaked[i]; + } + findings.push_back(r); + } +} + +// --- n03: getifaddrs VPN interfaces --- +static void detectGetifaddrsVpn(std::vector &findings) { + struct ifaddrs *head = nullptr; + if (getifaddrs(&head) != 0 || head == nullptr) return; + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + for (struct ifaddrs *cur = head; cur != nullptr; cur = cur->ifa_next) { + if (cur->ifa_name == nullptr) continue; + for (const auto &name : tunNames) { + if (cur->ifa_name == name) { + found.push_back(name); + break; + } + } + } + freeifaddrs(head); + if (!found.empty()) { + std::string r = "getifaddrs_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n04: /sys/class/net VPN interfaces --- +static void detectSysclassnetVpn(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + for (const auto &name : tunNames) { + std::string path = std::string("/sys/class/net/") + name; + struct stat st; + if (stat(path.c_str(), &st) == 0) { + found.push_back(name); + } + } + if (!found.empty()) { + std::string r = "sysclassnet_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n05: RTM_GETLINK VPN interfaces --- +static std::string extractRtattrString(const struct rtattr *attr) { + auto *data = reinterpret_cast(RTA_DATA(attr)); + if (data == nullptr || data[0] == '\0') return {}; + return {data}; +} + +static bool isVpnName(const std::string &name, const std::vector &tunNames) { + for (const auto &t : tunNames) { + if (t == name) return true; + } + return false; +} + +static void processNetlinkMsg( + const struct nlmsghdr *nh, + const std::vector &tunNames, + std::vector &found +) { + if (nh->nlmsg_type != RTM_NEWLINK) return; + auto *ifi = reinterpret_cast(NLMSG_DATA(nh)); + int rtaLen = nh->nlmsg_len - NLMSG_LENGTH(sizeof(*ifi)); + auto *attr = reinterpret_cast(ifi + 1); + std::string ifname; + for (; RTA_OK(attr, rtaLen); attr = RTA_NEXT(attr, rtaLen)) { + if (attr->rta_type == IFLA_IFNAME) { + ifname = extractRtattrString(attr); + break; + } + } + if (!ifname.empty() && isVpnName(ifname, tunNames)) { + found.push_back(ifname); + } +} + +static void detectRtmGetlinkVpn(std::vector &findings) { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (fd < 0) return; + + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifi; + } req{}; + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.nlh.nlmsg_type = RTM_GETLINK; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.nlh.nlmsg_seq = 1; + + struct sockaddr_nl kernel{}; + kernel.nl_family = AF_NETLINK; + + if (sendto(fd, &req, req.nlh.nlmsg_len, 0, + reinterpret_cast(&kernel), sizeof(kernel)) < 0) { + close(fd); + return; + } + + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + char buf[8192]; + bool done = false; + while (!done) { + struct pollfd pfd = {fd, POLLIN, 0}; + if (poll(&pfd, 1, 2000) <= 0) break; + ssize_t len = recv(fd, buf, sizeof(buf), 0); + if (len <= 0) break; + for (auto *nh = reinterpret_cast(buf); + NLMSG_OK(nh, static_cast(len)); + nh = NLMSG_NEXT(nh, len)) { + if (nh->nlmsg_type == NLMSG_DONE || nh->nlmsg_type == NLMSG_ERROR) { + done = true; + break; + } + processNetlinkMsg(nh, tunNames, found); + } + } + close(fd); + if (!found.empty()) { + std::string r = "rtm_getlink_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n10: /proc/net/if_inet6 VPN entries --- +static void detectProcIfInet6(std::vector &findings) { + const std::vector paths = {"/proc/net/if_inet6", "/proc/self/net/if_inet6"}; + const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + for (const auto &path : paths) { + std::ifstream f(path); + if (!f) continue; + int vpnCount = 0; + std::string vpnIfacesStr; + std::string line; + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string tokens[6]; + for (int i = 0; i < 6 && iss >> tokens[i]; ++i) {} + if (tokens[5].empty()) continue; + const auto &iface = tokens[5]; + bool match = false; + for (const auto &v : vpnIfaces) { if (v == iface) { match = true; break; } } + if (!match) continue; + vpnCount++; + if (!vpnIfacesStr.empty()) vpnIfacesStr += ", "; + vpnIfacesStr += iface; + } + if (vpnCount > 0) { + findings.emplace_back("proc_if_inet6_vpn|count=" + std::to_string(vpnCount) + " ifaces=" + vpnIfacesStr); + return; + } + } +} + +// --- n11: /proc/net/ipv6_route VPN entries --- +static void detectProcIpv6RouteVpn(std::vector &findings) { + const std::vector paths = {"/proc/net/ipv6_route", "/proc/self/net/ipv6_route"}; + const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + for (const auto &path : paths) { + std::ifstream f(path); + if (!f) continue; + int vpnCount = 0; + std::string line; + while (std::getline(f, line)) { + std::istringstream iss(line); + std::string tokens[10]; + for (int i = 0; i < 10 && iss >> tokens[i]; ++i) {} + if (tokens[9].empty()) continue; + const auto &iface = tokens[9]; + bool match = false; + for (const auto &v : vpnIfaces) { if (v == iface) { match = true; break; } } + if (match) vpnCount++; + } + if (vpnCount > 0) { + findings.emplace_back("proc_ipv6_route_vpn|count=" + std::to_string(vpnCount)); + return; + } + } +} + +// --- n16: /proc/net/dev VPN interface traffic --- +static void detectProcNetDevVpn(std::vector &findings) { + const std::vector paths = {"/proc/net/dev", "/proc/self/net/dev"}; + const std::vector vpnIfaces = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + for (const auto &path : paths) { + std::ifstream f(path); + if (!f) continue; + std::string line; + std::getline(f, line); // skip header 1 + std::getline(f, line); // skip header 2 + while (std::getline(f, line)) { + auto colon = line.find(':'); + if (colon == std::string::npos) continue; + std::string iface = line.substr(0, colon); + // trim leading whitespace + auto start = iface.find_first_not_of(" \t"); + if (start != std::string::npos) iface = iface.substr(start); + bool match = false; + for (const auto &v : vpnIfaces) { if (v == iface) { match = true; break; } } + if (!match) continue; + std::istringstream iss(line.substr(colon + 1)); + unsigned long rxBytes = 0, rxPackets = 0, txBytes = 0, txPackets = 0; + iss >> rxBytes >> rxPackets; + for (int i = 0; i < 6; ++i) { unsigned long tmp; iss >> tmp; } + iss >> txBytes >> txPackets; + findings.emplace_back("proc_net_dev_vpn|iface=" + iface + " rx=" + std::to_string(rxBytes) + " tx=" + std::to_string(txBytes)); + return; + } + } +} + +// --- n17: /proc/net/fib_trie SELinux access check --- +static void detectFibTrieAccess(std::vector &findings) { + FILE *f = fopen("/proc/net/fib_trie", "re"); + if (f) { + fclose(f); + } else { + if (errno == EACCES || errno == EPERM) { + findings.push_back("fib_trie_denied|SELinux denies /proc/net/fib_trie"); + } else if (errno == ENOENT) { + // not available on this kernel, neutral + } else { + std::string r = "fib_trie_denied|errno=" + std::to_string(errno); + findings.push_back(r); + } + } +} + +// --- n18: SO_BINDTODEVICE setsockopt --- +static void detectSetsockoptBindToDevice(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0"}; + for (const auto &name : tunNames) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) continue; + int rc = setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, name.c_str(), static_cast(name.size() + 1)); + if (rc == 0) { + char devName[256] = {0}; + socklen_t devLen = sizeof(devName); + if (getsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, devName, &devLen) == 0) { + std::string devStr(devName); + if (devStr.find(name) != std::string::npos) { + findings.emplace_back("bindtodevice_leak|setsockopt(" + name + ") succeeded, getsockopt confirmed"); + } + } + } + close(fd); + } +} + +// --- n19: inet_diag netlink access check --- +static void detectInetDiagAccess(std::vector &findings) { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_SOCK_DIAG); + if (fd < 0) { + if (errno == EACCES || errno == EPERM) { + findings.push_back("inet_diag_denied|SELinux denies inet_diag netlink"); + } + return; + } + close(fd); +} + +// --- n20: getsockname() VPN IP leak --- +static void detectGetsocknameLeak(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct sockaddr_in localAddr; + socklen_t addrLen = sizeof(localAddr); + std::memset(&localAddr, 0, sizeof(localAddr)); + if (getsockname(fd, reinterpret_cast(&localAddr), &addrLen) == 0) { + if (localAddr.sin_addr.s_addr != 0) { + char ip[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &localAddr.sin_addr, ip, sizeof(ip)); + // Check if it's a private IP that could be VPN + unsigned int addr = ntohl(localAddr.sin_addr.s_addr); + bool isPrivate = ((addr >> 24) == 10) || + ((addr >> 20) == 0xAC1) || // 172.16.0.0/12 + ((addr >> 16) == 0xC0A8); // 192.168.0.0/16 + if (isPrivate) { + std::string r = "getsockname_leak|local_ip=" + std::string(ip); + findings.push_back(r); + } + } + } + close(fd); +} + +// --- n21: VPN routing policy rules via netlink --- +static void detectVpnPolicyRules(std::vector &findings) { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (fd < 0) return; + + struct { + struct nlmsghdr nlh; + struct rtmsg rtm; + } req; + std::memset(&req, 0, sizeof(req)); + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.nlh.nlmsg_type = RTM_GETRULE; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.nlh.nlmsg_seq = 1; + req.rtm.rtm_family = AF_INET; + + struct sockaddr_nl kernel; + std::memset(&kernel, 0, sizeof(kernel)); + kernel.nl_family = AF_NETLINK; + + if (sendto(fd, &req, req.nlh.nlmsg_len, 0, + reinterpret_cast(&kernel), sizeof(kernel)) < 0) { + close(fd); + return; + } + + int vpnRuleCount = 0; + std::string ruleSummary; + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + + char buf[8192]; + while (true) { + struct pollfd pfd = {fd, POLLIN, 0}; + int pr = poll(&pfd, 1, 2000); + if (pr <= 0) break; + ssize_t len = recv(fd, buf, sizeof(buf), 0); + if (len <= 0) break; + bool done = false; + for (auto *nh = reinterpret_cast(buf); + NLMSG_OK(nh, static_cast(len)); + nh = NLMSG_NEXT(nh, len)) { + if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } + if (nh->nlmsg_type == NLMSG_ERROR) { done = true; break; } + if (nh->nlmsg_type != RTM_NEWRULE) continue; + + auto *rtm = reinterpret_cast(NLMSG_DATA(nh)); + int rtaLen = nh->nlmsg_len - NLMSG_LENGTH(sizeof(*rtm)); + auto *attr = reinterpret_cast(rtm + 1); + + int table = rtm->rtm_table; + unsigned int fwMark = 0; + int oif = 0; + char iface[IF_NAMESIZE] = {0}; + + for (; RTA_OK(attr, rtaLen); attr = RTA_NEXT(attr, rtaLen)) { + auto *data = RTA_DATA(attr); + int alen = RTA_PAYLOAD(attr); + switch (attr->rta_type) { + case RTA_TABLE: { + if (alen >= static_cast(sizeof(int))) { + table = *reinterpret_cast(data); + } + break; + } + case RTA_OIF: { + if (alen >= static_cast(sizeof(int))) { + oif = *reinterpret_cast(data); + if_indextoname(oif, iface); + } + break; + } + case RTA_FLOW: { + if (alen >= static_cast(sizeof(unsigned int))) { + fwMark = *reinterpret_cast(data); + } + break; + } + default: break; + } + } + + if (table >= 100 && table <= 200) { + // High table numbers are often VPN policy routing tables + bool isVpnIface = false; + for (const auto &name : tunNames) { + if (iface[0] && name == iface) { + isVpnIface = true; + break; + } + } + if (isVpnIface || (table >= 100 && table <= 110)) { + vpnRuleCount++; + if (!ruleSummary.empty()) ruleSummary += "; "; + ruleSummary += "table=" + std::to_string(table); + if (iface[0]) ruleSummary += " iface=" + std::string(iface); + if (fwMark) ruleSummary += " fwmark=0x" + std::to_string(fwMark); + } + } + + } + if (done) break; + } + close(fd); + + if (vpnRuleCount > 0) { + std::string r = "vpn_policy_rules_netlink|count=" + std::to_string(vpnRuleCount); + if (!ruleSummary.empty()) r += " " + ruleSummary; + findings.push_back(r); + } +} + +// --- n07: Route count / anonymous interfaces --- +static void detectRouteCount(std::vector &findings) { + auto routes = netlinkRouteDump(0); + int totalRoutes = 0; + for (const auto &r : routes) { + if (r.find("route|") == 0) totalRoutes++; + } + // Count unique interfaces + std::map ifaceCounts; + for (const auto &r : routes) { + auto devPos = r.find("|dev="); + if (devPos != std::string::npos) { + auto devEnd = r.find('|', devPos + 5); + std::string dev = (devEnd != std::string::npos) + ? r.substr(devPos + 5, devEnd - devPos - 5) + : r.substr(devPos + 5); + ifaceCounts[dev]++; + } + } + std::string r = "route_count|total=" + std::to_string(totalRoutes); + r += " interfaces=" + std::to_string(ifaceCounts.size()); + findings.push_back(r); +} + +// --- n41: UDP port conflict on physical interface IP --- +static void detectUdpPortConflictPhysicalIp(std::vector &findings) { + // Get physical interface IP via getifaddrs + struct ifaddrs *head = nullptr; + if (getifaddrs(&head) != 0 || head == nullptr) return; + + std::string physicalIp; + for (struct ifaddrs *cur = head; cur != nullptr; cur = cur->ifa_next) { + if (cur->ifa_name == nullptr || cur->ifa_addr == nullptr) continue; + if (cur->ifa_addr->sa_family != AF_INET) continue; + const char *name = cur->ifa_name; + // Skip tunnel/VPN interfaces + if (strstr(name, "tun") || strstr(name, "wg") || strstr(name, "ppp") || + strstr(name, "xfrm") || strstr(name, "utun")) continue; + if (strcmp(name, "lo") == 0) continue; + char ip[INET_ADDRSTRLEN]; + auto *in4 = reinterpret_cast(cur->ifa_addr); + inet_ntop(AF_INET, &in4->sin_addr, ip, sizeof(ip)); + physicalIp = ip; + break; // take the first physical IP + } + freeifaddrs(head); + + if (physicalIp.empty()) return; + + static const int kVpnPorts[] = {500, 4500, 1194, 1701, 51820}; + std::vector conflicts; + for (int port : kVpnPorts) { + int testFd = socket(AF_INET, SOCK_DGRAM, 0); + if (testFd < 0) continue; + int opt = 1; + setsockopt(testFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(port)); + inet_pton(AF_INET, physicalIp.c_str(), &addr.sin_addr); + if (bind(testFd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + if (errno == EADDRINUSE) { + conflicts.push_back(port); + } + } + close(testFd); + } + if (!conflicts.empty()) { + std::string r = "udp_port_conflict_physical|ip=" + physicalIp + " ports="; + for (size_t i = 0; i < conflicts.size(); ++i) { + if (i > 0) r += ","; + r += std::to_string(conflicts[i]); + } + findings.push_back(r); + } +} + +// --- n42: Trim oracle (bind probe vs RTM_GETLINK) --- +static void detectTrimOracle(std::vector &findings) { + // Count interfaces via bind probe + int bindCount = 0; + for (int ifindex = 1; ifindex < 128; ++ifindex) { + char name[IF_NAMESIZE] = {0}; + if (if_indextoname(static_cast(ifindex), name) != nullptr) { + bindCount++; + } + } + // Count interfaces via RTM_GETLINK + int rtmCount = 0; + { + int fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (fd >= 0) { + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifi; + } req; + std::memset(&req, 0, sizeof(req)); + req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.nlh.nlmsg_type = RTM_GETLINK; + req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.nlh.nlmsg_seq = 1; + struct sockaddr_nl kernel; + std::memset(&kernel, 0, sizeof(kernel)); + kernel.nl_family = AF_NETLINK; + if (sendto(fd, &req, req.nlh.nlmsg_len, 0, + reinterpret_cast(&kernel), sizeof(kernel)) >= 0) { + char buf[8192]; + while (true) { + struct pollfd pfd = {fd, POLLIN, 0}; + int pr = poll(&pfd, 1, 2000); + if (pr <= 0) break; + ssize_t len = recv(fd, buf, sizeof(buf), 0); + if (len <= 0) break; + bool done = false; + for (auto *nh = reinterpret_cast(buf); + NLMSG_OK(nh, static_cast(len)); + nh = NLMSG_NEXT(nh, len)) { + if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } + if (nh->nlmsg_type == NLMSG_ERROR) { done = true; break; } + if (nh->nlmsg_type == RTM_NEWLINK) rtmCount++; + } + if (done) break; + } + } + close(fd); + } + } + if (bindCount != rtmCount) { + std::string r = "trim_oracle|bind_probe=" + std::to_string(bindCount) + + " rtm_getlink=" + std::to_string(rtmCount) + " MISMATCH"; + findings.push_back(r); + } +} + +// --- n38: if_indexname VPN leak --- +static void detectIfindexnameVpn(std::vector &findings) { + const std::vector tunNames = {"tun0", "tun1", "utun0", "wg0", "ppp0", "xfrm0"}; + std::vector found; + for (int ifindex = 1; ifindex < 128; ++ifindex) { + char name[IF_NAMESIZE] = {0}; + if (if_indextoname(static_cast(ifindex), name) == nullptr) continue; + for (const auto &tun : tunNames) { + if (tun == name) { + found.push_back(std::string(name) + "(idx=" + std::to_string(ifindex) + ")"); + break; + } + } + } + if (!found.empty()) { + std::string r = "ifindexname_vpn|"; + for (size_t i = 0; i < found.size(); ++i) { + if (i > 0) r += ", "; + r += found[i]; + } + findings.push_back(r); + } +} + +// --- n22: UDP PMTU + TCP MSS combined --- +static void detectPmtuMssCombined(std::vector &findings) { + // Check TCP MSS + int tcpFd = socket(AF_INET, SOCK_STREAM, 0); + if (tcpFd >= 0) { + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(443); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(tcpFd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + struct tcp_info info; + socklen_t infoLen = sizeof(info); + if (getsockopt(tcpFd, IPPROTO_TCP, TCP_INFO, &info, &infoLen) == 0) { + unsigned int sndMss = info.tcpi_snd_mss; + unsigned int rcvMss = info.tcpi_rcv_mss; + std::string r = "pmtu_mss_combined|tcp_snd_mss=" + std::to_string(sndMss) + + " tcp_rcv_mss=" + std::to_string(rcvMss); + findings.push_back(r); + } + } + close(tcpFd); + } +} + +// --- n24: UDP send PMTU --- +static void detectUdpPmtu(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + // Try sending 1500 bytes (typical MTU) + char buf[1500]; + std::memset(buf, 0, sizeof(buf)); + ssize_t sent = sendto(fd, buf, sizeof(buf), 0, + reinterpret_cast(&addr), sizeof(addr)); + if (sent > 0) { + std::string r = "udp_pmtu_ok|sent=" + std::to_string(sent) + " bytes"; + findings.push_back(r); + } else { + std::string r = "udp_pmtu_fail|errno=" + std::to_string(errno); + findings.push_back(r); + } + close(fd); +} + +// --- n40: Normal path MTU --- +static void detectNormalPmtu(std::vector &findings) { + // Get MTU from primary interface + struct ifaddrs *head = nullptr; + if (getifaddrs(&head) != 0 || head == nullptr) return; + for (struct ifaddrs *cur = head; cur != nullptr; cur = cur->ifa_next) { + if (cur->ifa_name == nullptr) continue; + if (strcmp(cur->ifa_name, "lo") == 0) continue; + if (strstr(cur->ifa_name, "tun") || strstr(cur->ifa_name, "wg") || + strstr(cur->ifa_name, "ppp")) continue; + int mtu = fetchMtu(cur->ifa_name); + if (mtu > 0) { + std::string r = "normal_pmtu|iface=" + std::string(cur->ifa_name) + + " mtu=" + std::to_string(mtu); + findings.push_back(r); + break; + } + } + freeifaddrs(head); +} + +// --- n33: Traceroute probe --- +static void detectTraceroute(std::vector &findings) { + // Try a raw UDP traceroute-like probe to detect VPN encapsulation + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + // Set low TTL + int ttl = 1; + setsockopt(fd, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl)); + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(33434); // standard traceroute port + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + char buf[64]; + std::memset(buf, 0, sizeof(buf)); + // Non-blocking send to detect errors + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + ssize_t sent = sendto(fd, buf, sizeof(buf), 0, + reinterpret_cast(&addr), sizeof(addr)); + if (sent < 0 && errno == EACCES) { + findings.push_back("traceroute_denied|sendto denied"); + } + close(fd); +} + +// --- n34: Timing oracle (CNTVCT) --- +static void detectTimingOracle(std::vector &findings) { +#if defined(__aarch64__) + // Measure ARM cycle counter for socket operations + auto readCycleCounter = []() -> uint64_t { + uint64_t val; + asm volatile("mrs %0, cntvct_el0" : "=r"(val)); + return val; + }; + const int iterations = 10; + uint64_t totalCycles = 0; + uint64_t minCycles = UINT64_MAX; + uint64_t maxCycles = 0; + for (int i = 0; i < iterations; ++i) { + uint64_t start = readCycleCounter(); + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd >= 0) { + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + char buf[64] = {0}; + sendto(fd, buf, sizeof(buf), 0, reinterpret_cast(&addr), sizeof(addr)); + close(fd); + } + uint64_t end = readCycleCounter(); + uint64_t elapsed = end - start; + totalCycles += elapsed; + if (elapsed < minCycles) minCycles = elapsed; + if (elapsed > maxCycles) maxCycles = elapsed; + } + uint64_t avgCycles = totalCycles / static_cast(iterations); + std::string r = "timing_oracle|min=" + std::to_string(minCycles) + + " max=" + std::to_string(maxCycles) + + " avg=" + std::to_string(avgCycles); + findings.push_back(r); +#else + findings.push_back("timing_oracle|not_arm64"); +#endif +} + +// --- n35: Backpressure flood test --- +static bool isScanCancelled(JNIEnv *env, jobject cancellationSignal, jmethodID isCancelledMethod) { + if (cancellationSignal == nullptr || isCancelledMethod == nullptr) return true; + jboolean cancelled = env->CallBooleanMethod(cancellationSignal, isCancelledMethod); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return true; + } + return cancelled == JNI_TRUE; +} + +static void detectBackpressure( + JNIEnv *env, + jobject cancellationSignal, + jmethodID isCancelledMethod, + std::vector &findings +) { + if (isScanCancelled(env, cancellationSignal, isCancelledMethod)) return; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + const int pktCount = 50000; + const int pktSize = 1400; + char buf[pktSize]; + std::memset(buf, 0, sizeof(buf)); + int sent = 0; + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < pktCount; ++i) { + if ((i & 63) == 0 && isScanCancelled(env, cancellationSignal, isCancelledMethod)) { + close(fd); + return; + } + ssize_t rc = sendto(fd, buf, sizeof(buf), MSG_DONTWAIT, + reinterpret_cast(&addr), sizeof(addr)); + if (rc < 0) break; + sent++; + } + auto end = std::chrono::steady_clock::now(); + close(fd); + if (isScanCancelled(env, cancellationSignal, isCancelledMethod)) return; + double elapsedMs = std::chrono::duration(end - start).count(); + double throughputMB = (static_cast(sent) * pktSize) / (1024.0 * 1024.0) / (elapsedMs / 1000.0); + std::string r = "backpressure|" + std::to_string(sent) + "/" + std::to_string(pktCount) + + " pkts sent in " + std::to_string(static_cast(elapsedMs)) + "ms (" + + std::to_string(static_cast(throughputMB)) + " MB/s)"; + findings.push_back(r); +} + +// --- n36: GSO large send test --- +static void detectGsoLargeSend(std::vector &findings) { +#if defined(__linux__) + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + // Use a segment size that fits a normal path MTU. GSO availability is + // diagnostic only; lack of support is not VPN evidence. + int gsoSize = 1200; + int rc = setsockopt(fd, IPPROTO_UDP, UDP_SEGMENT, &gsoSize, sizeof(gsoSize)); + if (rc < 0) { + std::string r = "gso_failed|errno=" + std::to_string(errno); + findings.push_back(r); + } else { + // GSO accepted, try sending + struct sockaddr_in addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(53); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + char buf[4800]; + std::memset(buf, 0, sizeof(buf)); + ssize_t sent = sendto(fd, buf, sizeof(buf), 0, + reinterpret_cast(&addr), sizeof(addr)); + if (sent < 0) { + std::string r = "gso_send_failed|errno=" + std::to_string(errno); + findings.push_back(r); + } else { + findings.push_back("gso_ok"); + } + } + close(fd); +#endif +} + +// --- n37: Hardware timestamping check --- +static void detectHwTimestamp(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return; + struct hwtstamp_config hwconfig; + std::memset(&hwconfig, 0, sizeof(hwconfig)); + hwconfig.tx_type = HWTSTAMP_TX_ON; + struct ifreq ifr; + std::memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1); + ifr.ifr_data = reinterpret_cast(&hwconfig); + int rc = ioctl(fd, SIOCSHWTSTAMP, &ifr); + if (rc == 0) { + findings.push_back("hw_timestamp|lo configured"); + } + // Also check for SCM_TIMESTAMPING + int tsFd = socket(AF_INET, SOCK_DGRAM, 0); + if (tsFd >= 0) { + int tsFlags = SOF_TIMESTAMPING_SOFTWARE; + setsockopt(tsFd, SOL_SOCKET, SO_TIMESTAMPING, &tsFlags, sizeof(tsFlags)); + close(tsFd); + } + close(fd); +} + +// --- n33: Traceroute (already defined above, skipping duplicate) --- + +static void detectLoopbackConflicts(std::vector &findings) { + static const int kVpnPorts[] = {51820, 1194, 443, 8443}; + for (int port : kVpnPorts) { + int testFd = socket(AF_INET, SOCK_STREAM, 0); + if (testFd < 0) continue; + int opt = 1; + setsockopt(testFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(testFd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + if (errno == EADDRINUSE) { + std::string r = "loopback_port_conflict|port="; r.append(std::to_string(port)); + findings.push_back(r); + } + } + close(testFd); + } +} + +static void detectSocketLeaks(std::vector &findings) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd >= 0) { + struct sockaddr_in localAddr; + socklen_t addrLen = sizeof(localAddr); + memset(&localAddr, 0, sizeof(localAddr)); + if (getsockname(fd, (struct sockaddr *)&localAddr, &addrLen) == 0) { + if (localAddr.sin_addr.s_addr != 0) { + char ip[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &localAddr.sin_addr, ip, sizeof(ip)); + std::string r = "so_bindtodevice|local_ip="; r.append(ip); + findings.push_back(r); + } + } + close(fd); + } +} + const JNINativeMethod kMethods[] = { {"nativeGetIfAddrs", "()[Ljava/lang/String;", reinterpret_cast(nativeGetIfAddrs)}, {"nativeIfNameToIndex", "(Ljava/lang/String;)I", reinterpret_cast(nativeIfNameToIndex)}, @@ -1030,9 +2250,62 @@ const JNINativeMethod kMethods[] = { {"nativeNetlinkSockDiag", "(II)[Ljava/lang/String;", reinterpret_cast(nativeNetlinkSockDiag)}, {"nativeDetectRoot", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectRoot)}, {"nativeDetectEmulator", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectEmulator)}, + {"nativeDetectVpnProperties", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnProperties)}, + {"nativeDetectVpnLeaks", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnLeaks)}, + {"nativeDetectVpnAdvanced", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnAdvanced)}, + {"nativeDetectVpnSyscalls", "()[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnSyscalls)}, + {"nativeDetectVpnDetector", "(Lcom/notcvnt/rknhardering/ScanCancellationSignal;)[Ljava/lang/String;", reinterpret_cast(nativeDetectVpnDetector)}, }; -} // namespace +// New consolidated VPN detector: returns ONLY the new deep checks (prefixed +// with "vdet|") so they can live in their own UI category, separate from the +// legacy NativeSignsChecker output. +jobjectArray nativeDetectVpnDetector(JNIEnv *env, jclass /*clazz*/, jobject cancellationSignal) { + std::vector raw; + jmethodID isCancelledMethod = nullptr; + if (cancellationSignal != nullptr) { + jclass cancellationClass = env->GetObjectClass(cancellationSignal); + if (cancellationClass != nullptr) { + isCancelledMethod = env->GetMethodID(cancellationClass, "isCancelled", "()Z"); + env->DeleteLocalRef(cancellationClass); + } + if (env->ExceptionCheck()) { + env->ExceptionClear(); + isCancelledMethod = nullptr; + } + } + detectSysfsLeak(raw); + detectGetifaddrsVpn(raw); + detectSysclassnetVpn(raw); + detectRtmGetlinkVpn(raw); + detectProcIfInet6(raw); + detectProcIpv6RouteVpn(raw); + detectProcNetDevVpn(raw); + detectIfindexnameVpn(raw); + detectRouteCount(raw); + detectNormalPmtu(raw); + detectFibTrieAccess(raw); + detectInetDiagAccess(raw); + detectVpnPolicyRules(raw); + detectTrimOracle(raw); + detectPmtuMssCombined(raw); + detectUdpPmtu(raw); + detectTraceroute(raw); + detectTimingOracle(raw); + detectBackpressure(env, cancellationSignal, isCancelledMethod, raw); + detectGsoLargeSend(raw); + detectHwTimestamp(raw); + detectSetsockoptBindToDevice(raw); + detectGetsocknameLeak(raw); + detectUdpPortConflictPhysicalIp(raw); + + std::vector prefixed; + prefixed.reserve(raw.size()); + for (const auto &line : raw) { + prefixed.push_back("vdet|" + line); + } + return toStringArray(env, prefixed); +} JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { JNIEnv *env = nullptr; @@ -1041,7 +2314,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void * /*reserved*/) { } jclass cls = env->FindClass("com/notcvnt/rknhardering/probe/NativeSignsBridge"); if (cls == nullptr) return JNI_ERR; - jint rc = env->RegisterNatives(cls, kMethods, sizeof(kMethods) / sizeof(kMethods[0])); + jint rc = env->RegisterNatives(cls, kMethods, static_cast(std::size(kMethods))); env->DeleteLocalRef(cls); if (rc != JNI_OK) return JNI_ERR; return JNI_VERSION_1_6; diff --git a/app/src/main/java/com/notcvnt/rknhardering/CheckViewModel.kt b/app/src/main/java/com/notcvnt/rknhardering/CheckViewModel.kt index 72cc1d4..a814912 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/CheckViewModel.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/CheckViewModel.kt @@ -7,6 +7,7 @@ import com.notcvnt.rknhardering.checker.CheckSettings import com.notcvnt.rknhardering.checker.CheckUpdate import com.notcvnt.rknhardering.checker.VpnCheckRunner import com.notcvnt.rknhardering.customcheck.CustomCheckRunner +import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector import com.notcvnt.rknhardering.model.CheckResult import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -14,9 +15,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch sealed interface ScanEvent { - data class Started(val settings: CheckSettings, val privacyMode: Boolean) : ScanEvent + data class Started( + val settings: CheckSettings, + val privacyMode: Boolean, + val resultDisplayMode: ResultDisplayMode = ResultDisplayMode.NORMAL, + ) : ScanEvent data class Update(val update: CheckUpdate) : ScanEvent - data class Completed(val result: CheckResult, val privacyMode: Boolean) : ScanEvent + data class Completed( + val result: CheckResult, + val privacyMode: Boolean, + val resultDisplayMode: ResultDisplayMode = ResultDisplayMode.NORMAL, + ) : ScanEvent data object Cancelled : ScanEvent } @@ -46,14 +55,32 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) { private var completedDiagnosticsConsumed = false fun startScan(settings: CheckSettings, privacyMode: Boolean) { + val resultDisplayMode = ResultDisplayMode.fromPrefs(AppUiSettings.prefs(getApplication())) + startScan(settings, privacyMode, resultDisplayMode) + } + + fun startScan( + settings: CheckSettings, + privacyMode: Boolean, + resultDisplayMode: ResultDisplayMode, + ) { if (scanJob?.isActive == true && activeExecutionContext?.cancellationSignal?.isCancelled() == false) return val scanId = nextScanId++ - val executionContext = ScanExecutionContext(scanId = scanId) + val diagnosticCollector = if (resultDisplayMode == ResultDisplayMode.ADVANCED) { + DiagnosticTraceCollector(privacyMode) + } else { + null + } + val executionContext = ScanExecutionContext( + scanId = scanId, + resultDisplayMode = resultDisplayMode, + diagnosticCollector = diagnosticCollector, + ) activeScanId = scanId activeExecutionContext = executionContext resetCompletedDiagnosticsRetention() - replaceScanEvents(scanId, ScanEvent.Started(settings, privacyMode)) + replaceScanEvents(scanId, ScanEvent.Started(settings, privacyMode, resultDisplayMode)) _isRunning.value = true lateinit var launchedJob: Job @@ -82,7 +109,7 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) { } } if (isCurrentScan(scanId, executionContext)) { - val enrichedResult = try { + val resultWithProfile = try { val app: Application = getApplication() val customEnabled = AppUiSettings.prefs(app) .getBoolean(SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED, false) @@ -94,9 +121,17 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) { } catch (_: Exception) { result } - appendScanEvent(scanId, ScanEvent.Completed(enrichedResult, privacyMode)) + val enrichedResult = if (resultWithProfile.diagnosticSnapshot == null) { + resultWithProfile.copy( + diagnosticSnapshot = executionContext.diagnosticCollector?.snapshot(), + ) + } else { + resultWithProfile + } + appendScanEvent(scanId, ScanEvent.Completed(enrichedResult, privacyMode, resultDisplayMode)) } } catch (e: kotlinx.coroutines.CancellationException) { + executionContext.diagnosticCollector?.clear() if (isCurrentScan(scanId, executionContext)) { appendScanEvent(scanId, ScanEvent.Cancelled) } @@ -124,6 +159,7 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) { activeExecutionContext = null activeScanId = null _isRunning.value = false + executionContext.diagnosticCollector?.clear() executionContext.cancellationSignal.cancel() scanJob?.cancel() } diff --git a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt index 8f0555a..d96c6f3 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt @@ -11,6 +11,7 @@ import android.os.Build import android.os.Bundle import android.provider.Settings import android.view.View +import android.view.ViewGroup import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout @@ -53,11 +54,15 @@ import com.notcvnt.rknhardering.model.IpComparisonResult import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.ObservedIp import com.notcvnt.rknhardering.model.StunProbeGroupResult +import com.notcvnt.rknhardering.model.VerdictRuleCode import com.notcvnt.rknhardering.export.CompletedExportSnapshot import com.notcvnt.rknhardering.export.createCompletedExportSnapshot import com.notcvnt.rknhardering.network.DnsResolverConfig import com.notcvnt.rknhardering.ui.main.CategoryTiles import com.notcvnt.rknhardering.ui.main.MainExportController +import com.notcvnt.rknhardering.ui.main.SimpleCardModel +import com.notcvnt.rknhardering.ui.main.SimpleResultModels +import com.notcvnt.rknhardering.ui.main.SimpleResultStatus import com.notcvnt.rknhardering.ui.main.TileSpec import com.notcvnt.rknhardering.ui.main.render.BypassRenderer import com.notcvnt.rknhardering.ui.main.render.CallTransportRenderer @@ -68,6 +73,8 @@ import com.notcvnt.rknhardering.ui.main.render.FindingViewFactory import com.notcvnt.rknhardering.ui.main.render.IpChannelsRenderer import com.notcvnt.rknhardering.ui.main.render.IpComparisonRenderer import com.notcvnt.rknhardering.ui.main.render.MainRenderEnvironment +import com.notcvnt.rknhardering.ui.main.render.SimpleResultRenderer +import com.notcvnt.rknhardering.ui.main.render.TechnicalDataRenderer import com.notcvnt.rknhardering.ui.main.render.VerdictRenderer import com.notcvnt.rknhardering.ui.main.render.VerdictViews import kotlinx.coroutines.Job @@ -199,6 +206,7 @@ class MainActivity : AppCompatActivity() { private var userTouchScrollInProgress = false private var isAutoScrollInProgress = false private var activeCheckPrivacyMode = false + private var activeResultDisplayMode = ResultDisplayMode.NORMAL private var activeCheckSettings: CheckSettings? = null private var retainedDiagnosticsSnapshot: RetainedDiagnosticsSnapshot? = null private var completedExportSnapshot: CompletedExportSnapshot? = null @@ -267,6 +275,8 @@ class MainActivity : AppCompatActivity() { private val ipChannelsRenderer by lazy { IpChannelsRenderer(renderEnv) } private val domainReachabilityRenderer by lazy { DomainReachabilityRenderer(renderEnv) } private val verdictRenderer by lazy { VerdictRenderer(renderEnv, findingViews) } + private val simpleResultRenderer by lazy { SimpleResultRenderer(renderEnv) } + private val technicalDataRenderer by lazy { TechnicalDataRenderer(renderEnv) } override fun onCreate(savedInstanceState: Bundle?) { AppUiSettings.applySavedTheme(this) @@ -652,7 +662,8 @@ class MainActivity : AppCompatActivity() { private fun onRunCheckClicked() { if (viewModel.isRunning.value) return - runCheck() + val resultDisplayMode = ResultDisplayMode.fromPrefs(prefs) + runCheck(resultDisplayMode) } private fun isDebugClipboardExportEnabled(): Boolean { @@ -749,7 +760,7 @@ class MainActivity : AppCompatActivity() { Toast.makeText(this, R.string.main_diagnostics_copied, Toast.LENGTH_SHORT).show() } - private fun runCheck() { + private fun runCheck(resultDisplayMode: ResultDisplayMode) { val splitTunnelEnabled = prefs.getBoolean(SettingsActivity.PREF_SPLIT_TUNNEL_ENABLED, true) val proxyScanEnabled = prefs.getBoolean(SettingsActivity.PREF_PROXY_SCAN_ENABLED, true) val proxyAuthProbeEnabled = prefs.getBoolean(SettingsActivity.PREF_PROXY_AUTH_PROBE_ENABLED, false) @@ -803,7 +814,7 @@ class MainActivity : AppCompatActivity() { val activeProfile = if (customEnabled) com.notcvnt.rknhardering.customcheck.CustomCheckRunner.getActiveProfile(this) else null val effectiveSettings = activeProfile?.let { com.notcvnt.rknhardering.customcheck.CustomCheckRunner.toCheckSettings(it, baseSettings) } ?: baseSettings - viewModel.startScan(effectiveSettings, privacyMode) + viewModel.startScan(effectiveSettings, privacyMode, resultDisplayMode) } private fun observeScanEvents() { @@ -825,6 +836,7 @@ class MainActivity : AppCompatActivity() { prepareCheckSessionUi( firstEvent.settings, firstEvent.privacyMode, + firstEvent.resultDisplayMode, ) processedEventCount = 1 while (processedEventCount < events.size) { @@ -850,7 +862,18 @@ class MainActivity : AppCompatActivity() { } private fun prepareCheckSessionUi(settings: CheckSettings, privacyMode: Boolean) { + prepareCheckSessionUi(settings, privacyMode, ResultDisplayMode.NORMAL) + } + + private fun prepareCheckSessionUi( + settings: CheckSettings, + privacyMode: Boolean, + resultDisplayMode: ResultDisplayMode, + ) { + simpleResultRenderer.clear() + technicalDataRenderer.clear() activeCheckPrivacyMode = privacyMode + activeResultDisplayMode = resultDisplayMode activeCheckSettings = settings retainedDiagnosticsSnapshot = null completedExportSnapshot = null @@ -864,12 +887,18 @@ class MainActivity : AppCompatActivity() { resetBypassProgress() clearStageContent() resetAllTiles() + applyResultDisplayTitles() applyTileVisibilityFromSettings(settings) if (settings.callTransportProbeEnabled) { setTileStatus(CATEGORY_STN, TILE_STATUS_NEUTRAL, getString(R.string.tile_hint_loading)) } bindVerdictHeroRunning() showAllLoadingCardsNow(settings) + if (activeResultDisplayMode == ResultDisplayMode.SIMPLE) { + tiles.filterValues { it.card.isVisible }.keys.forEach { id -> + renderSimpleCard(id, SimpleCardModel(SimpleResultStatus.RUNNING)) + } + } updateResultActionButtonsVisibility() } @@ -892,8 +921,12 @@ class MainActivity : AppCompatActivity() { is ScanEvent.Started -> Unit is ScanEvent.Update -> { handleCheckUpdate(event.update, animate = animate) + if (activeResultDisplayMode == ResultDisplayMode.SIMPLE) { + renderSimpleUpdate(event.update) + } } is ScanEvent.Completed -> { + activeResultDisplayMode = event.resultDisplayMode retainedDiagnosticsSnapshot = if (viewModel.canRetainCompletedDiagnostics()) { retainCompletedDiagnosticsSnapshot( result = event.result, @@ -911,11 +944,17 @@ class MainActivity : AppCompatActivity() { refreshCompletedCategoryViews(event.result, event.privacyMode) displayVerdict(event.result, event.privacyMode) bindVerdictHero(event.result) + when (activeResultDisplayMode) { + ResultDisplayMode.SIMPLE -> renderSimpleCompletedResult(event.result) + ResultDisplayMode.ADVANCED -> renderTechnicalData(event.result) + ResultDisplayMode.NORMAL -> Unit + } if (animate) animateContentReveal(verdictHero) stopLoadingStatusAnimation() updateResultActionButtonsVisibility() } is ScanEvent.Cancelled -> { + technicalDataRenderer.clear() activeCheckSettings = null resetBypassProgress() statusBypass.text = getString(R.string.main_status_cancelled) @@ -940,6 +979,167 @@ class MainActivity : AppCompatActivity() { } } + private fun applyResultDisplayTitles() { + CategoryTiles.ALL.forEach { spec -> + tiles[spec.id]?.title?.setText( + if (activeResultDisplayMode == ResultDisplayMode.SIMPLE) { + simpleTitleForCategory(spec.id) + } else { + spec.titleRes + }, + ) + } + } + + private fun renderSimpleUpdate(update: CheckUpdate) { + when (update) { + is CheckUpdate.GeoIpReady -> renderSimpleCard(CATEGORY_GEO, SimpleResultModels.category(update.result)) + is CheckUpdate.IpComparisonReady -> renderSimpleCard(CATEGORY_IPC, SimpleResultModels.ipComparison(update.result)) + is CheckUpdate.CdnPullingReady -> renderSimpleCard(CATEGORY_CDN, SimpleResultModels.cdn(update.result)) + is CheckUpdate.IcmpSpoofingReady -> renderSimpleCard(CATEGORY_ICM, SimpleResultModels.category(update.result)) + is CheckUpdate.RttTriangulationReady -> renderSimpleCard( + CATEGORY_RTT, + SimpleResultModels.category(update.result, extraInformation = true), + ) + is CheckUpdate.DirectSignsReady -> renderSimpleCard(CATEGORY_DIR, SimpleResultModels.category(update.result)) + is CheckUpdate.IndirectSignsReady -> { + renderSimpleCard(CATEGORY_IND, SimpleResultModels.category(update.result)) + if (tiles[CATEGORY_STN]?.card?.isVisible == true) { + renderSimpleCard( + CATEGORY_STN, + SimpleResultModels.callTransport(update.result.callTransportLeaks, update.result.stunProbeGroups), + ) + } + } + is CheckUpdate.LocationSignalsReady -> renderSimpleCard(CATEGORY_LOC, SimpleResultModels.category(update.result)) + is CheckUpdate.NativeSignsReady -> renderSimpleCard(CATEGORY_NAT, SimpleResultModels.category(update.result)) + is CheckUpdate.BypassReady -> renderSimpleCard(CATEGORY_BYP, SimpleResultModels.bypass(update.result)) + is CheckUpdate.DomainReachabilityReady -> renderSimpleCard( + CATEGORY_REA, + SimpleResultModels.domainReachability(update.result), + ) + is CheckUpdate.BypassProgress, + is CheckUpdate.IpConsensusReady, + is CheckUpdate.VerdictReady, + -> Unit + } + } + + private fun renderSimpleCompletedResult(result: CheckResult) { + renderSimpleCard(CATEGORY_GEO, SimpleResultModels.category(result.geoIp)) + renderSimpleCard(CATEGORY_IPC, SimpleResultModels.ipComparison(result.ipComparison)) + renderSimpleCard(CATEGORY_CDN, SimpleResultModels.cdn(result.cdnPulling)) + renderSimpleCard(CATEGORY_DIR, SimpleResultModels.category(result.directSigns)) + renderSimpleCard(CATEGORY_IND, SimpleResultModels.category(result.indirectSigns)) + renderSimpleCard(CATEGORY_NAT, SimpleResultModels.category(result.nativeSigns)) + renderSimpleCard( + CATEGORY_STN, + SimpleResultModels.callTransport( + result.indirectSigns.callTransportLeaks, + result.indirectSigns.stunProbeGroups, + ), + ) + renderSimpleCard(CATEGORY_ICM, SimpleResultModels.category(result.icmpSpoofing)) + renderSimpleCard( + CATEGORY_RTT, + SimpleResultModels.category(result.rttTriangulation, extraInformation = true), + ) + renderSimpleCard(CATEGORY_LOC, SimpleResultModels.category(result.locationSignals)) + renderSimpleCard(CATEGORY_BYP, SimpleResultModels.bypass(result.bypassResult)) + renderSimpleCard(CATEGORY_REA, SimpleResultModels.domainReachability(result.domainReachability)) + verdictSubtitle.text = simpleVerdictExplanation(result.verdictDecision.rule) + textVerdictExplanation.text = simpleVerdictExplanation(result.verdictDecision.rule) + } + + private fun renderSimpleCard(id: String, model: SimpleCardModel) { + val holder = tiles[id] ?: return + simpleResultRenderer.render(holder.body as ViewGroup, simpleCheckDescription(id), model) + setTileStatus(id, tileStatus(model.status), simpleStatusText(model.status)) + } + + private fun renderTechnicalData(result: CheckResult) { + technicalDataRenderer.clear() + val snapshot = result.diagnosticSnapshot + val entries = snapshot?.entries.orEmpty() + tiles.values.filter { it.card.isVisible }.forEach { holder -> + technicalDataRenderer.render( + holder.body as ViewGroup, + entries.filter { it.category == holder.id }, + runTruncated = snapshot?.truncated == true, + ) + } + } + + private fun tileStatus(status: SimpleResultStatus): Int = when (status) { + SimpleResultStatus.RUNNING, + SimpleResultStatus.DISABLED, + -> TILE_STATUS_NEUTRAL + SimpleResultStatus.CLEAN -> TILE_STATUS_CLEAN + SimpleResultStatus.REVIEW -> TILE_STATUS_REVIEW + SimpleResultStatus.DETECTED -> TILE_STATUS_DETECTED + SimpleResultStatus.ERROR -> TILE_STATUS_ERROR + } + + private fun simpleStatusText(status: SimpleResultStatus): String = getString( + when (status) { + SimpleResultStatus.RUNNING -> R.string.simple_result_running + SimpleResultStatus.CLEAN -> R.string.simple_result_clean + SimpleResultStatus.REVIEW -> R.string.simple_result_review + SimpleResultStatus.DETECTED -> R.string.simple_result_detected + SimpleResultStatus.ERROR -> R.string.simple_result_error + SimpleResultStatus.DISABLED -> R.string.simple_result_disabled + }, + ) + + private fun simpleTitleForCategory(id: String): Int = when (id) { + CATEGORY_GEO -> R.string.simple_title_geo + CATEGORY_IPC -> R.string.simple_title_ip_comparison + CATEGORY_CDN -> R.string.simple_title_cdn + CATEGORY_DIR -> R.string.simple_title_direct + CATEGORY_IND -> R.string.simple_title_indirect + CATEGORY_NAT -> R.string.simple_title_native + CATEGORY_STN -> R.string.simple_title_call_transport + CATEGORY_ICM -> R.string.simple_title_icmp + CATEGORY_RTT -> R.string.simple_title_rtt + CATEGORY_LOC -> R.string.simple_title_location + CATEGORY_BYP -> R.string.simple_title_bypass + CATEGORY_REA -> R.string.simple_title_reachability + else -> R.string.section_check_details + } + + private fun simpleCheckDescription(id: String): String = getString( + when (id) { + CATEGORY_GEO -> R.string.simple_checked_geo + CATEGORY_IPC -> R.string.simple_checked_ip_comparison + CATEGORY_CDN -> R.string.simple_checked_cdn + CATEGORY_DIR -> R.string.simple_checked_direct + CATEGORY_IND -> R.string.simple_checked_indirect + CATEGORY_NAT -> R.string.simple_checked_native + CATEGORY_STN -> R.string.simple_checked_call_transport + CATEGORY_ICM -> R.string.simple_checked_icmp + CATEGORY_RTT -> R.string.simple_checked_rtt + CATEGORY_LOC -> R.string.simple_checked_location + CATEGORY_BYP -> R.string.simple_checked_bypass + CATEGORY_REA -> R.string.simple_checked_reachability + else -> R.string.section_check_details + }, + ) + + private fun simpleVerdictExplanation(rule: VerdictRuleCode): String = getString( + when (rule) { + VerdictRuleCode.R1_HARD_BYPASS -> R.string.simple_verdict_hard_bypass + VerdictRuleCode.R3_PROBE_TARGET_DIVERGENCE, + VerdictRuleCode.R3_PROBE_TARGET_DIRECT_DIVERGENCE, + VerdictRuleCode.R3_CROSS_CHANNEL_MISMATCH, + -> R.string.simple_verdict_address_conflict + VerdictRuleCode.R4_LOCATION_GEO_CONFLICT -> R.string.simple_verdict_location_conflict + VerdictRuleCode.R4_HOSTING_REVIEW -> R.string.simple_verdict_hosting_review + VerdictRuleCode.R5_MATRIX -> R.string.simple_verdict_combined_signals + VerdictRuleCode.R6_FALLBACK -> R.string.simple_verdict_review_signals + VerdictRuleCode.UNSPECIFIED -> R.string.simple_verdict_general + }, + ) + private fun refreshCompletedCategoryViews(result: CheckResult, privacyMode: Boolean) { if (cardCdnPulling.isVisible || result.cdnPulling != CdnPullingResult.empty()) { displayCdnPulling(result.cdnPulling, privacyMode) @@ -1209,6 +1409,7 @@ class MainActivity : AppCompatActivity() { } is CheckUpdate.IpConsensusReady -> { markStageCompleted(RunningStage.IP_CONSENSUS) + updateTileFromIpConsensus(update.result) } is CheckUpdate.DomainReachabilityReady -> { markStageCompleted(RunningStage.DOMAIN_REACHABILITY) diff --git a/app/src/main/java/com/notcvnt/rknhardering/ResultDisplayMode.kt b/app/src/main/java/com/notcvnt/rknhardering/ResultDisplayMode.kt new file mode 100644 index 0000000..2aacb22 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/ResultDisplayMode.kt @@ -0,0 +1,18 @@ +package com.notcvnt.rknhardering + +import android.content.SharedPreferences + +enum class ResultDisplayMode(val prefValue: String) { + SIMPLE("simple"), + NORMAL("normal"), + ADVANCED("advanced"), + ; + + companion object { + fun fromPref(value: String?): ResultDisplayMode = + entries.firstOrNull { it.prefValue == value } ?: NORMAL + + fun fromPrefs(prefs: SharedPreferences): ResultDisplayMode = + fromPref(prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null)) + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/ScanExecutionContext.kt b/app/src/main/java/com/notcvnt/rknhardering/ScanExecutionContext.kt index e0c7f27..ead3832 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/ScanExecutionContext.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/ScanExecutionContext.kt @@ -1,5 +1,6 @@ package com.notcvnt.rknhardering +import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector import kotlinx.coroutines.CancellationException import kotlinx.coroutines.asContextElement import java.net.DatagramSocket @@ -14,6 +15,8 @@ private val scanExecutionContextThreadLocal = ThreadLocal data class ScanExecutionContext( val scanId: Long = 0L, val cancellationSignal: ScanCancellationSignal = ScanCancellationSignal(), + val resultDisplayMode: ResultDisplayMode = ResultDisplayMode.NORMAL, + val diagnosticCollector: DiagnosticTraceCollector? = null, ) { fun asCoroutineContext(): CoroutineContext = scanExecutionContextThreadLocal.asContextElement(this) diff --git a/app/src/main/java/com/notcvnt/rknhardering/SettingsAppearanceFragment.kt b/app/src/main/java/com/notcvnt/rknhardering/SettingsAppearanceFragment.kt index 901e49e..f945a00 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/SettingsAppearanceFragment.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/SettingsAppearanceFragment.kt @@ -3,6 +3,7 @@ package com.notcvnt.rknhardering import android.content.SharedPreferences import android.os.Bundle import android.view.View +import android.widget.TextView import androidx.core.content.edit import androidx.fragment.app.Fragment import com.google.android.material.chip.ChipGroup @@ -11,6 +12,8 @@ import com.google.android.material.snackbar.Snackbar internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_appearance) { private lateinit var prefs: SharedPreferences + private lateinit var chipGroupResultDisplayMode: ChipGroup + private lateinit var textResultDisplayModeDescription: TextView private lateinit var chipGroupTheme: ChipGroup private lateinit var chipGroupIconStyle: ChipGroup private lateinit var chipGroupLanguage: ChipGroup @@ -18,6 +21,8 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) prefs = AppUiSettings.prefs(requireContext()) + chipGroupResultDisplayMode = view.findViewById(R.id.chipGroupResultDisplayMode) + textResultDisplayModeDescription = view.findViewById(R.id.textResultDisplayModeDescription) chipGroupTheme = view.findViewById(R.id.chipGroupTheme) chipGroupIconStyle = view.findViewById(R.id.chipGroupIconStyle) chipGroupLanguage = view.findViewById(R.id.chipGroupLanguage) @@ -26,6 +31,16 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_ } private fun loadSettings() { + val resultDisplayMode = ResultDisplayMode.fromPrefs(prefs) + chipGroupResultDisplayMode.check( + when (resultDisplayMode) { + ResultDisplayMode.SIMPLE -> R.id.chipResultDisplaySimple + ResultDisplayMode.NORMAL -> R.id.chipResultDisplayNormal + ResultDisplayMode.ADVANCED -> R.id.chipResultDisplayAdvanced + }, + ) + updateResultDisplayModeDescription(resultDisplayMode) + val theme = prefs.getString(SettingsPrefs.PREF_THEME, "system") ?: "system" chipGroupTheme.check( when (theme) { @@ -53,6 +68,17 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_ } private fun setupListeners(view: View) { + chipGroupResultDisplayMode.setOnCheckedStateChangeListener { _, checkedIds -> + if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener + val mode = when (checkedIds.first()) { + R.id.chipResultDisplaySimple -> ResultDisplayMode.SIMPLE + R.id.chipResultDisplayAdvanced -> ResultDisplayMode.ADVANCED + else -> ResultDisplayMode.NORMAL + } + prefs.edit { putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, mode.prefValue) } + updateResultDisplayModeDescription(mode) + } + chipGroupTheme.setOnCheckedStateChangeListener { _, checkedIds -> if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener val value = when (checkedIds.first()) { @@ -86,6 +112,16 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_ } } + private fun updateResultDisplayModeDescription(mode: ResultDisplayMode) { + textResultDisplayModeDescription.setText( + when (mode) { + ResultDisplayMode.SIMPLE -> R.string.settings_result_display_mode_simple_desc + ResultDisplayMode.NORMAL -> R.string.settings_result_display_mode_normal_desc + ResultDisplayMode.ADVANCED -> R.string.settings_result_display_mode_advanced_desc + }, + ) + } + private fun applyIconStyle(view: View, isClassic: Boolean) { val rawMode = prefs.getString(SettingsPrefs.PREF_COLOR_VISION_MODE, null) val mode = ColorVisionMode.fromPref(rawMode) diff --git a/app/src/main/java/com/notcvnt/rknhardering/SettingsCategoriesFragment.kt b/app/src/main/java/com/notcvnt/rknhardering/SettingsCategoriesFragment.kt index 6ec5587..db85793 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/SettingsCategoriesFragment.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/SettingsCategoriesFragment.kt @@ -141,6 +141,11 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_ } private fun appearanceValue(): String { + val resultDisplayMode = when (ResultDisplayMode.fromPrefs(prefs)) { + ResultDisplayMode.SIMPLE -> getString(R.string.settings_result_display_mode_simple) + ResultDisplayMode.NORMAL -> getString(R.string.settings_result_display_mode_normal) + ResultDisplayMode.ADVANCED -> getString(R.string.settings_result_display_mode_advanced) + } val theme = when (prefs.getString(SettingsPrefs.PREF_THEME, "system")) { "light" -> getString(R.string.settings_theme_light) "dark" -> getString(R.string.settings_theme_dark) @@ -153,7 +158,7 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_ "zh-CN" -> "ZH" else -> currentLocaleCode() } - return getString(R.string.settings_value_appearance_format, theme, language) + return getString(R.string.settings_value_appearance_format, theme, language, resultDisplayMode) } private fun colorVisionValue(): String { diff --git a/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt b/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt index 1db924b..bec455e 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/SettingsCustomCheckEditorFragment.kt @@ -162,7 +162,7 @@ internal class SettingsCustomCheckEditorFragment : // Override expansion listener to track openSectionIds sectionView.findViewById(R.id.sectionHeader).setOnClickListener { - val nowOpen = !controller.isExpanded() + val nowOpen = !controller.isExpanded controller.setExpanded(nowOpen, animate = true) if (nowOpen) openSectionIds.add(id) else openSectionIds.remove(id) } @@ -225,7 +225,7 @@ internal class SettingsCustomCheckEditorFragment : labelHintRes = R.string.settings_custom_check_description_field, extraInputHintRes = R.string.settings_custom_check_check_type_field, extraSwitchTextRes = null, - testAction = { domain, checkType, _ -> + testAction = { domain, _, _ -> kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { val result = runCatching { val client = okhttp3.OkHttpClient.Builder() diff --git a/app/src/main/java/com/notcvnt/rknhardering/SettingsPrefs.kt b/app/src/main/java/com/notcvnt/rknhardering/SettingsPrefs.kt index 0e9b3e0..1d3a8b1 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/SettingsPrefs.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/SettingsPrefs.kt @@ -24,6 +24,7 @@ internal object SettingsPrefs { const val PREF_DNS_RESOLVER_DOH_URL = "pref_dns_resolver_doh_url" const val PREF_DNS_RESOLVER_DOH_BOOTSTRAP = "pref_dns_resolver_doh_bootstrap" const val PREF_PRIVACY_MODE = "pref_privacy_mode" + const val PREF_RESULT_DISPLAY_MODE = "pref_result_display_mode" const val PREF_THEME = "pref_theme" const val PREF_LANGUAGE = "pref_language" const val PREF_COLOR_VISION_MODE = "pref_color_vision_mode" diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt index ff8e81b..250eab3 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt @@ -31,9 +31,10 @@ object CdnPullingChecker { private const val MAX_FETCH_ATTEMPTS = 1 private const val RETRY_DELAY_MS = 250L + private const val MEDUZA_IO = "meduza.io" private val ACTIONABLE_TARGETS = setOf( "redirector.googlevideo.com", - "meduza.io", + MEDUZA_IO, ) internal data class EndpointSpec( @@ -71,7 +72,7 @@ object CdnPullingChecker { kind = CdnPullingClient.TargetKind.CLOUDFLARE_TRACE, ), EndpointSpec( - label = "meduza.io", + label = MEDUZA_IO, url = "https://meduza.io/cdn-cgi/trace", kind = CdnPullingClient.TargetKind.CLOUDFLARE_TRACE, ), @@ -92,7 +93,7 @@ object CdnPullingChecker { val activeEndpoints = buildList { if (config.builtinTargetsEnabled) { for (endpoint in ENDPOINTS) { - if (endpoint.label == "meduza.io" && !config.meduzaEnabled) continue + if (endpoint.label == MEDUZA_IO && !config.meduzaEnabled) continue if (endpoint.label == "rutracker.org" && !config.rutrackerEnabled) continue add(endpoint) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt index 3c5a4ad..5214db0 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt @@ -580,9 +580,7 @@ object GeoIpChecker { config = resolverConfig, cancellationSignal = executionContext.cancellationSignal, ) - if (response.code !in 200..299) { - throw IllegalStateException("HTTP ${response.code}") - } + check(response.code in 200..299) { "HTTP ${response.code}" } return JSONObject(response.body) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/IcmpSpoofingChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/IcmpSpoofingChecker.kt index c141d21..b5eb622 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/IcmpSpoofingChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/IcmpSpoofingChecker.kt @@ -14,6 +14,7 @@ import com.notcvnt.rknhardering.network.ResolverNetworkStack import com.notcvnt.rknhardering.probe.SystemPingProber import java.io.IOException import java.net.Inet4Address +import java.net.InetAddress import java.util.Locale import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -47,8 +48,8 @@ object IcmpSpoofingChecker { config = resolverConfig, cancellationSignal = ScanExecutionContext.currentOrDefault().cancellationSignal, ) - val ipv4 = addresses.firstOrNull { it is Inet4Address } - ?: throw IOException("No IPv4 address resolved for $host") + val ipv4 = addresses.filterIsInstance().firstOrNull(::isUsablePublicIpv4) + ?: throw IOException("No usable public IPv4 address resolved for $host") ipv4.hostAddress ?: throw IOException("Resolved IPv4 address is empty for $host") }, val ping: suspend (String, Int, Int) -> SystemPingProber.PingResult = { address, count, timeoutSeconds -> @@ -94,6 +95,14 @@ object IcmpSpoofingChecker { effectiveTargets.map { target -> async { val address = dependencies.resolveIpv4(target.host, resolverConfig) + val parsedAddress = if (DnsResolverConfig.isValidIpLiteral(address)) { + runCatching { InetAddress.getByName(address) }.getOrNull() as? Inet4Address + } else { + null + } + if (parsedAddress == null || !isUsablePublicIpv4(parsedAddress)) { + throw IOException("No usable public IPv4 address resolved for ${target.host}") + } val pingResult = dependencies.ping(address, pingCount, timeoutSeconds) TargetOutcome(target = target, address = address, ping = pingResult) } @@ -264,4 +273,31 @@ object IcmpSpoofingChecker { private fun formatRtt(value: Double?): String { return value?.let { String.format(Locale.US, "%.1f ms", it) } ?: "n/a" } + + internal fun isUsablePublicIpv4(address: Inet4Address): Boolean { + if ( + address.isAnyLocalAddress || + address.isLoopbackAddress || + address.isLinkLocalAddress || + address.isSiteLocalAddress || + address.isMulticastAddress + ) { + return false + } + + val bytes = address.address.map { it.toInt() and 0xff } + val first = bytes[0] + val second = bytes[1] + val third = bytes[2] + return when { + first == 0 -> false + first == 100 && second in 64..127 -> false + first == 192 && second == 0 && (third == 0 || third == 2) -> false + first == 198 && second in 18..19 -> false + first == 198 && second == 51 && third == 100 -> false + first == 203 && second == 0 && third == 113 -> false + first >= 240 -> false + else -> true + } + } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsDumpsysLogic.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsDumpsysLogic.kt index 506cc52..e6f20ac 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsDumpsysLogic.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/IndirectSignsDumpsysLogic.kt @@ -3,6 +3,7 @@ package com.notcvnt.rknhardering.checker import android.content.Context import android.os.Build import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.ScanExecutionContext import com.notcvnt.rknhardering.model.ActiveVpnApp import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceItem @@ -11,6 +12,7 @@ import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.vpn.VpnAppCatalog import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner import com.notcvnt.rknhardering.vpn.VpnDumpsysParser +import kotlin.concurrent.thread internal fun checkDumpsysVpn( context: Context, @@ -20,9 +22,7 @@ internal fun checkDumpsysVpn( ): SignalOutcome { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return SignalOutcome() return try { - val process = Runtime.getRuntime().exec(arrayOf("dumpsys", "vpn_management")) - val output = process.inputStream.bufferedReader().readText() - process.waitFor() + val output = executeDumpsys(listOf("dumpsys", "vpn_management")) if (VpnDumpsysParser.isUnavailable(output)) { findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_vpn_unavailable))) @@ -108,9 +108,7 @@ internal fun checkDumpsysVpnService( activeApps: MutableList, ): SignalOutcome { return try { - val process = Runtime.getRuntime().exec(arrayOf("dumpsys", "activity", "services", "android.net.VpnService")) - val output = process.inputStream.bufferedReader().readText() - process.waitFor() + val output = executeDumpsys(listOf("dumpsys", "activity", "services", "android.net.VpnService")) if (VpnDumpsysParser.isUnavailable(output)) { findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_service_unavailable))) @@ -192,3 +190,55 @@ internal fun checkDumpsysVpnService( SignalOutcome() } } + +private fun executeDumpsys(command: List): String { + val executionContext = ScanExecutionContext.currentOrDefault() + val startedAt = System.nanoTime() + val process = ProcessBuilder(command).start() + val registration = executionContext.cancellationSignal.register { + runCatching { process.destroyForcibly() } + } + return try { + var stdout = "" + var stderr = "" + val stdoutReader = thread(name = "rkn-dumpsys-stdout") { + stdout = runCatching { process.inputStream.bufferedReader().use { it.readText() } }.getOrDefault("") + } + val stderrReader = thread(name = "rkn-dumpsys-stderr") { + stderr = runCatching { process.errorStream.bufferedReader().use { it.readText() } }.getOrDefault("") + } + val exitCode = process.waitFor() + stdoutReader.join() + stderrReader.join() + executionContext.throwIfCancelled() + executionContext.diagnosticCollector?.record( + category = "ind", + source = "dumpsys", + target = command.drop(1).joinToString(" "), + status = "exit $exitCode", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = buildString { + appendLine("command=${command.joinToString(" ")}") + appendLine("exitCode=$exitCode") + appendLine("stdout:") + appendLine(stdout) + appendLine("stderr:") + append(stderr) + }, + ) + stdout + } catch (error: Exception) { + executionContext.diagnosticCollector?.record( + category = "ind", + source = "dumpsys", + target = command.drop(1).joinToString(" "), + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = error.message.orEmpty(), + ) + throw error + } finally { + registration.dispose() + runCatching { process.destroy() } + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt index 428a02f..0358851 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/NativeSignsChecker.kt @@ -2,6 +2,7 @@ package com.notcvnt.rknhardering.checker import android.content.Context import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.rethrowIfCancellation import com.notcvnt.rknhardering.model.CategoryResult import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceItem @@ -38,6 +39,7 @@ object NativeSignsChecker { ) private const val ARPHRD_TUNTAP = 65534 + private const val RTPROT_KERNEL = 2 private val HIGH_CONFIDENCE_ROOT_MOUNT_MARKERS = setOf( "magisk", @@ -153,6 +155,31 @@ object NativeSignsChecker { evidence += isolationOutcome.evidence needsReview = needsReview || isolationOutcome.needsReview + val vpnProps = runCatching { NativeInterfaceProbe.collectVpnPropertyFindings() }.getOrDefault(emptyList()) + val vpnLeaks = runCatching { NativeInterfaceProbe.collectVpnLeakFindings() }.getOrDefault(emptyList()) + val vpnAdvanced = runCatching { NativeInterfaceProbe.collectVpnAdvancedFindings() }.getOrDefault(emptyList()) + val vpnSyscalls = runCatching { NativeInterfaceProbe.collectVpnSyscallFindings() }.getOrDefault(emptyList()) + val vpnOutcome = evaluateVpnSignals(context, vpnProps, vpnLeaks, vpnAdvanced, vpnSyscalls) + findings += vpnOutcome.findings + evidence += vpnOutcome.evidence + detected = detected || vpnOutcome.detected + needsReview = needsReview || vpnOutcome.needsReview + + // Deep VPN detector (ported from reference APK) — kept as its own + // sub-section inside the Native category for clarity. + val detectorOutcome = try { + VpnNativeDetectorChecker.check(context) + } catch (error: Throwable) { + rethrowIfCancellation(error) + null + } + if (detectorOutcome != null) { + findings += detectorOutcome.findings + evidence += detectorOutcome.evidence + detected = detected || detectorOutcome.detected + needsReview = needsReview || detectorOutcome.needsReview + } + if (findings.isEmpty()) { findings += Finding( description = context.getString(R.string.checker_native_no_anomalies), @@ -492,6 +519,7 @@ object NativeSignsChecker { !route.isDefault && (route.prefixLen == 32 || route.prefixLen == 128) && route.destination != null && + !isKernelManagedLocalRoute(route) && isPublicRoutableAddress(route.destination) && NetworkInterfacePatterns.isStandardInterface(route.interfaceName) } @@ -520,6 +548,37 @@ object NativeSignsChecker { return PartialOutcome(findings, evidence, detected = detected) } + /** + * Excludes kernel-managed entries that are not VPN host-route leaks: + * - every entry from the kernel `local` table (255), even if a vendor reports + * incomplete type/scope metadata + * - `type=local` (the interface's own address) / `broadcast` / `anycast` / `multicast` + * - `scope=host` (the route never leaves the box) + * - `dst == prefsrc` (route destination equals the interface's own source address) + * - kernel-created link routes on cellular modem interfaces; carriers use these + * for service addressing and they exist independently of a VPN (issue #80) + * A genuine VPN server host-route leak is a `unicast` route to a *foreign* public IP + * (dst != prefsrc) in the main/policy tables. + */ + internal fun isKernelManagedLocalRoute(route: NativeRouteEntry): Boolean { + if (route.table == 255) return true + when (route.type?.lowercase()) { + "local", "broadcast", "anycast", "multicast" -> return true + } + if (route.scope?.lowercase() == "host") return true + val dst = route.destination + val src = route.prefSrc + if (dst != null && src != null && dst == src) return true + if ( + route.protocol == RTPROT_KERNEL && + route.scope?.lowercase() == "link" && + NetworkInterfacePatterns.isCellularModemInterface(route.interfaceName) + ) { + return true + } + return false + } + internal fun isPublicRoutableAddress(addr: String): Boolean { if (!com.notcvnt.rknhardering.customcheck.UrlSanitizer.isPublicAddress(addr)) return false // UrlSanitizer does not cover IPv6 ULA fc00::/7 — add it here @@ -916,11 +975,163 @@ object NativeSignsChecker { }.getOrDefault(false) } + internal fun evaluateVpnSignals( + context: Context, + props: List, + leaks: List, + advanced: List, + syscalls: List, + ): PartialOutcome { + val findings = mutableListOf() + val evidence = mutableListOf() + var detected = false + var needsReview = false + + val propsByKind = props.groupBy { it.kind } + val leaksByKind = leaks.groupBy { it.kind } + val advancedByKind = advanced.groupBy { it.kind } + val syscallsByKind = syscalls.filter { !it.kind.startsWith("unavailable") }.groupBy { it.kind } + + val allKinds = listOf( + "vpn_prop", "dns_prop", "vpn_file", "vpnhide", "lsposed", "hook_prop", + "tcp_vpn_port", "udp_vpn_port", "inet6_vpn_iface", "route_vpn_iface", + "arp_vpn_iface", "sysctl_forwarding", "sysctl_rp_filter", "established_vpn", + "vpn_policy_rules", "vpn_qdisc", "hidden_mac_neighbors", "tcp_mss_low", + "so_bindtodevice", "loopback_port_conflict", "bpf_map_accessible", "ip_recverr", + ) + + val unavailableSyscalls = syscalls.filter { it.kind.startsWith("unavailable") } + + for (kind in allKinds) { + val items = propsByKind[kind] + ?: leaksByKind[kind] + ?: advancedByKind[kind] + ?: syscallsByKind[kind] + + val isHigh = kind == "vpn_prop" || kind == "vpnhide" || kind == "hook_prop" || + kind == "udp_vpn_port" || kind == "route_vpn_iface" || + kind == "arp_vpn_iface" || kind == "inet6_vpn_iface" || + kind == "vpn_policy_rules" || kind == "hidden_mac_neighbors" || + kind == "tcp_mss_low" || kind == "loopback_port_conflict" || + kind == "bpf_map_accessible" + + val source = when { + kind.contains("hook") || kind.contains("lsposed") || kind.contains("vpnhide") -> EvidenceSource.NATIVE_HOOK_MARKERS + kind.contains("route") || kind.contains("policy") || kind.contains("sysctl") || + kind.contains("qdisc") -> EvidenceSource.NATIVE_ROUTE + kind.contains("inet6") || kind.contains("neigh") || kind.contains("mac") || + kind.contains("arp") -> EvidenceSource.NATIVE_INTERFACE + else -> EvidenceSource.NATIVE_SOCKET + } + + if (items.isNullOrEmpty()) { + findings += Finding( + description = context.getString(R.string.checker_native_vpn_signal, "${context.getString(getVpnDescResId(kind))} — ${context.getString(R.string.vpn_status_not_found)}"), + detected = false, + isInformational = true, + source = source, + confidence = EvidenceConfidence.LOW, + ) + } else { + for (item in items) { + val detail = when (item) { + is com.notcvnt.rknhardering.probe.NativeVpnPropertyFinding -> + if (item.value != null) "${item.prop}=${item.value}" else (item.prop ?: item.kind) + is com.notcvnt.rknhardering.probe.NativeVpnLeakFinding -> + if (item.count > 0) "${item.detail} (×${item.count})" else (item.detail ?: item.kind) + is com.notcvnt.rknhardering.probe.NativeVpnAdvancedFinding -> + if (item.count > 0) "${item.detail} (×${item.count})" else (item.detail ?: item.kind) + is com.notcvnt.rknhardering.probe.NativeVpnSyscallFinding -> + item.detail ?: item.kind + else -> kind + } + findings += Finding( + description = context.getString(R.string.checker_native_vpn_signal, describeVpnFinding(context, kind, detail)), + detected = isHigh, + needsReview = !isHigh, + source = source, + confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM, + ) + evidence += EvidenceItem(source = source, detected = true, confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM, description = describeVpnFinding(context, kind, detail)) + detected = detected || isHigh + needsReview = needsReview || !isHigh + } + } + } + + for (item in unavailableSyscalls) { + val reason = item.detail ?: item.kind.removePrefix("unavailable|") + findings += Finding( + description = context.getString(R.string.checker_native_vpn_signal, "${reason} — ${context.getString(R.string.vpn_status_unavailable)}"), + detected = false, + isInformational = true, + source = EvidenceSource.NATIVE_SOCKET, + confidence = EvidenceConfidence.LOW, + ) + } + + return PartialOutcome(findings, evidence, detected = detected, needsReview = needsReview) + } + + private fun getVpnDescResId(kind: String): Int = when (kind) { + "vpn_prop" -> R.string.vpn_desc_prop + "vpnhide" -> R.string.vpn_desc_vpnhide + "hook_prop" -> R.string.vpn_desc_hook + "dns_prop" -> R.string.vpn_desc_dns + "vpn_file" -> R.string.vpn_desc_file + "lsposed" -> R.string.vpn_desc_lsposed + "tcp_vpn_port" -> R.string.vpn_desc_tcp_port + "udp_vpn_port" -> R.string.vpn_desc_udp_port + "inet6_vpn_iface" -> R.string.vpn_desc_inet6 + "route_vpn_iface" -> R.string.vpn_desc_route + "arp_vpn_iface" -> R.string.vpn_desc_arp + "sysctl_forwarding" -> R.string.vpn_desc_forwarding + "sysctl_rp_filter" -> R.string.vpn_desc_rpfilter + "established_vpn" -> R.string.vpn_desc_established + "vpn_policy_rules" -> R.string.vpn_desc_policy_rules + "vpn_qdisc" -> R.string.vpn_desc_qdisc + "hidden_mac_neighbors" -> R.string.vpn_desc_hidden_mac + "tcp_mss_low" -> R.string.vpn_desc_mss + "so_bindtodevice" -> R.string.vpn_desc_bindtodevice + "loopback_port_conflict" -> R.string.vpn_desc_port_conflict + "bpf_map_accessible" -> R.string.vpn_desc_bpf + "ip_recverr" -> R.string.vpn_desc_recverr + else -> R.string.checker_native_vpn_signal + } + internal fun containsHighConfidenceRootMountMarker(detail: String): Boolean { val normalized = detail.lowercase() return HIGH_CONFIDENCE_ROOT_MOUNT_MARKERS.any { marker -> normalized.contains(marker) } } -} + private fun describeVpnFinding(context: Context, kind: String, detail: String): String { + val resId = when (kind) { + "vpn_prop" -> R.string.vpn_desc_prop + "vpnhide" -> R.string.vpn_desc_vpnhide + "hook_prop" -> R.string.vpn_desc_hook + "dns_prop" -> R.string.vpn_desc_dns + "vpn_file" -> R.string.vpn_desc_file + "lsposed" -> R.string.vpn_desc_lsposed + "tcp_vpn_port" -> R.string.vpn_desc_tcp_port + "udp_vpn_port" -> R.string.vpn_desc_udp_port + "inet6_vpn_iface" -> R.string.vpn_desc_inet6 + "route_vpn_iface" -> R.string.vpn_desc_route + "arp_vpn_iface" -> R.string.vpn_desc_arp + "sysctl_forwarding" -> R.string.vpn_desc_forwarding + "sysctl_rp_filter" -> R.string.vpn_desc_rpfilter + "established_vpn" -> R.string.vpn_desc_established + "vpn_policy_rules" -> R.string.vpn_desc_policy_rules + "vpn_qdisc" -> R.string.vpn_desc_qdisc + "hidden_mac_neighbors" -> R.string.vpn_desc_hidden_mac + "tcp_mss_low" -> R.string.vpn_desc_mss + "so_bindtodevice" -> R.string.vpn_desc_bindtodevice + "loopback_port_conflict" -> R.string.vpn_desc_port_conflict + "bpf_map_accessible" -> R.string.vpn_desc_bpf + "ip_recverr" -> R.string.vpn_desc_recverr + else -> return "$kind: $detail" + } + return "$detail — ${context.getString(resId)}" + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt index a0b329e..5627ffb 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt @@ -4,9 +4,13 @@ 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.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.Verdict +import com.notcvnt.rknhardering.model.VerdictDecision +import com.notcvnt.rknhardering.model.VerdictParticipant +import com.notcvnt.rknhardering.model.VerdictRuleCode object VerdictEngine { @@ -53,10 +57,39 @@ object VerdictEngine { nativeSigns: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()), icmpSpoofing: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()), geoCheckAvailable: Boolean = true, - ): Verdict { + ): Verdict = evaluateDetailed( + geoIp = geoIp, + directSigns = directSigns, + indirectSigns = indirectSigns, + locationSignals = locationSignals, + bypassResult = bypassResult, + ipConsensus = ipConsensus, + nativeSigns = nativeSigns, + icmpSpoofing = icmpSpoofing, + geoCheckAvailable = geoCheckAvailable, + ).verdict + + fun evaluateDetailed( + geoIp: CategoryResult, + directSigns: CategoryResult, + indirectSigns: CategoryResult, + locationSignals: CategoryResult, + bypassResult: BypassResult, + ipConsensus: IpConsensusResult, + nativeSigns: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()), + icmpSpoofing: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()), + geoCheckAvailable: Boolean = true, + ): VerdictDecision { // R1 - if (bypassResult.evidence.any { it.detected && it.source in HARD_DETECT_BYPASS }) { - return Verdict.DETECTED + val hardBypassSources = bypassResult.evidence + .filter { it.detected && it.source in HARD_DETECT_BYPASS } + .mapTo(linkedSetOf()) { it.source } + if (hardBypassSources.isNotEmpty()) { + return decision( + Verdict.DETECTED, + VerdictRuleCode.R1_HARD_BYPASS, + participant("hard_bypass", hardBypassSources), + ) } val geoAxis = ipConsensus.foreignIps.isNotEmpty() || @@ -64,13 +97,28 @@ object VerdictEngine { ipConsensus.warpLikeIndicator // R3 if (ipConsensus.probeTargetDivergence && geoAxis) { - return Verdict.DETECTED + return decision( + Verdict.DETECTED, + VerdictRuleCode.R3_PROBE_TARGET_DIVERGENCE, + participant("probe_target_divergence"), + participant("geo_axis"), + ) } if (ipConsensus.probeTargetDirectDivergence && geoAxis) { - return Verdict.DETECTED + return decision( + Verdict.DETECTED, + VerdictRuleCode.R3_PROBE_TARGET_DIRECT_DIVERGENCE, + participant("probe_target_direct_divergence"), + participant("geo_axis"), + ) } if (ipConsensus.crossChannelMismatch && geoAxis) { - return Verdict.DETECTED + return decision( + Verdict.DETECTED, + VerdictRuleCode.R3_CROSS_CHANNEL_MISMATCH, + participant("cross_channel_mismatch"), + participant("geo_axis"), + ) } // R4 @@ -92,21 +140,36 @@ object VerdictEngine { ipConsensus.probeTargetDivergence || ipConsensus.probeTargetDirectDivergence if (locationConfirmsRussia && geo?.outsideRu == true && !expectedRoamingExit) { - return Verdict.DETECTED + return decision( + Verdict.DETECTED, + VerdictRuleCode.R4_LOCATION_GEO_CONFLICT, + participant("location_confirms_russia", setOf(EvidenceSource.LOCATION_SIGNALS)), + participant("geo_outside_russia", setOf(EvidenceSource.GEO_IP)), + ) } if (locationConfirmsRussia && (geo?.hosting == true || geo?.proxyDb == true) && geo.outsideRu != true && !anyOtherSignal ) { - return Verdict.NEEDS_REVIEW + return decision( + Verdict.NEEDS_REVIEW, + VerdictRuleCode.R4_HOSTING_REVIEW, + participant("location_confirms_russia", setOf(EvidenceSource.LOCATION_SIGNALS)), + participant("hosting_or_proxy_database", setOf(EvidenceSource.GEO_IP)), + ) } // R5 — 3-bit matrix (geo x direct x indirect) val geoHit = geo?.outsideRu == true && !expectedRoamingExit val directHit = directSigns.evidence.any { it.detected && it.source in MATRIX_DIRECT_SOURCES } val indirectHit = indirectSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } || - nativeSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } + nativeSigns.evidence.any { + it.detected && ( + it.source in MATRIX_INDIRECT_SOURCES || + (it.source == EvidenceSource.NATIVE_SOCKET && it.confidence == EvidenceConfidence.HIGH) + ) + } val matrix = when { !geoHit && !directHit && !indirectHit -> Verdict.NOT_DETECTED !geoHit && directHit && !indirectHit -> Verdict.NOT_DETECTED @@ -125,7 +188,12 @@ object VerdictEngine { it.status == CallTransportStatus.NEEDS_REVIEW && it.networkPath != CallTransportNetworkPath.LOCAL_PROXY } - val nativeReviewHit = nativeSigns.evidence.any { it.detected && it.source in NATIVE_REVIEW_SOURCES } + val nativeReviewHit = nativeSigns.evidence.any { + it.detected && ( + it.source in NATIVE_REVIEW_SOURCES || + (it.source == EvidenceSource.NATIVE_SOCKET && it.confidence == EvidenceConfidence.HIGH) + ) + } val tunProbeReview = directSigns.evidence.any { it.source == EvidenceSource.TUN_ACTIVE_PROBE && !it.detected } @@ -144,9 +212,53 @@ object VerdictEngine { tunProbeReview ) ) { - return Verdict.NEEDS_REVIEW + val participants = buildList { + if (bypassResult.needsReview) add(participant("bypass_needs_review", bypassResult.evidence.mapTo(linkedSetOf()) { it.source })) + if (directSigns.needsReview) add(participant("direct_needs_review", directSigns.evidence.mapTo(linkedSetOf()) { it.source })) + if (indirectSigns.needsReview) add(participant("indirect_needs_review", indirectSigns.evidence.mapTo(linkedSetOf()) { it.source })) + if (locationSignalHit) add(participant("location_signal", setOf(EvidenceSource.LOCATION_SIGNALS))) + if (hasActionableCallTransportLeak) add(participant("call_transport_leak")) + if (icmpSpoofing.needsReview) add(participant("icmp_needs_review", setOf(EvidenceSource.ICMP_SPOOFING))) + if (nativeReviewHit) add(participant("native_needs_review", nativeSigns.evidence.mapTo(linkedSetOf()) { it.source })) + if (ipConsensus.needsReview) add(participant("ip_consensus_needs_review")) + if (ipConsensus.channelConflict.isNotEmpty()) add(participant("ip_channel_conflict")) + if (ipConsensus.probeTargetDivergence) add(participant("probe_target_divergence")) + if (tunProbeReview) add(participant("tun_probe_review", setOf(EvidenceSource.TUN_ACTIVE_PROBE))) + } + return decision(Verdict.NEEDS_REVIEW, VerdictRuleCode.R6_FALLBACK, *participants.toTypedArray()) } - return matrix + val matrixSources = buildSet { + if (directHit) addAll(directSigns.evidence.filter { it.detected && it.source in MATRIX_DIRECT_SOURCES }.map { it.source }) + if (indirectHit) { + addAll(indirectSigns.evidence.filter { it.detected && it.source in MATRIX_INDIRECT_SOURCES }.map { it.source }) + addAll(nativeSigns.evidence.filter { it.detected }.map { it.source }) + } + if (geoHit) add(EvidenceSource.GEO_IP) + } + return decision( + matrix, + VerdictRuleCode.R5_MATRIX, + participant("geo=$geoHit"), + participant("direct=$directHit"), + participant("indirect=$indirectHit", matrixSources), + participant("geo_available=$geoAxisAvailable"), + participant("expected_roaming_exit=$expectedRoamingExit"), + ) } + + private fun participant( + factor: String, + sources: Set = emptySet(), + ): VerdictParticipant = VerdictParticipant(factor = factor, evidenceSources = sources) + + private fun decision( + verdict: Verdict, + rule: VerdictRuleCode, + vararg participants: VerdictParticipant, + ): VerdictDecision = VerdictDecision( + verdict = verdict, + rule = rule, + participants = participants.toList(), + ) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt index c9da538..e996f77 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt @@ -25,6 +25,7 @@ import com.notcvnt.rknhardering.model.BypassResult import com.notcvnt.rknhardering.model.CdnPullingResult import com.notcvnt.rknhardering.model.CategoryResult import com.notcvnt.rknhardering.model.CheckResult +import com.notcvnt.rknhardering.diagnostics.DiagnosticResultRecorder import com.notcvnt.rknhardering.model.DomainReachabilityResult import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceSource @@ -669,7 +670,7 @@ object VpnCheckRunner { onUpdate?.invoke(CheckUpdate.IpConsensusReady(ipConsensus)) executionContext.throwIfCancelled() - val verdict = VerdictEngine.evaluate( + val verdictDecision = VerdictEngine.evaluateDetailed( geoIp = geoIp, directSigns = directSigns, indirectSigns = indirectSigns, @@ -680,11 +681,50 @@ object VpnCheckRunner { icmpSpoofing = icmpSpoofing, geoCheckAvailable = settings.networkRequestsEnabled, ) + val verdict = verdictDecision.verdict executionContext.throwIfCancelled() onUpdate?.invoke(CheckUpdate.VerdictReady(verdict)) val operatorWhitelistResult = operatorWhitelistDeferred?.await() + val diagnosticCollector = executionContext.diagnosticCollector + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "geo", "GeoIpChecker", geoIp) + DiagnosticResultRecorder.recordIpComparison(diagnosticCollector, ipComparison) + DiagnosticResultRecorder.recordCdn(diagnosticCollector, cdnPulling) + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "dir", "DirectSignsChecker", directSigns) + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "ind", "IndirectSignsChecker", indirectSigns) + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "loc", "LocationSignalsChecker", locationSignals) + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "nat", "NativeSignsChecker", nativeSigns) + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "icmp", "IcmpSpoofingChecker", icmpSpoofing) + DiagnosticResultRecorder.recordCategory(diagnosticCollector, "rtt", "RttTriangulationChecker", rttTriangulation) + DiagnosticResultRecorder.recordBypass(diagnosticCollector, bypassResult) + tunProbeResult?.let { probe -> + diagnosticCollector?.record( + category = "byp", + source = "UnderlyingNetworkProber", + status = when { + probe.underlyingReachable -> "reachable" + probe.vpnError != null || probe.underlyingError != null -> "error" + else -> "completed" + }, + body = buildString { + appendLine("vpnActive=${probe.vpnActive}") + appendLine("activeNetworkIsVpn=${probe.activeNetworkIsVpn}") + appendLine("underlyingReachable=${probe.underlyingReachable}") + appendLine("dnsPathMismatch=${probe.dnsPathMismatch}") + appendLine("vpnError=${probe.vpnError}") + appendLine("underlyingError=${probe.underlyingError}") + appendLine("ruTarget=${probe.ruTarget}") + appendLine("nonRuTarget=${probe.nonRuTarget}") + appendLine("tunProbeDiagnostics=${probe.tunProbeDiagnostics}") + }, + ) + } + DiagnosticResultRecorder.recordDomainReachability(diagnosticCollector, domainReachability) + DiagnosticResultRecorder.recordConsensus(diagnosticCollector, ipConsensus) + DiagnosticResultRecorder.recordVerdict(diagnosticCollector, verdictDecision) + val diagnosticSnapshot = diagnosticCollector?.snapshot() + CheckResult( geoIp = geoIp, ipComparison = ipComparison, @@ -701,6 +741,8 @@ object VpnCheckRunner { ipConsensus = ipConsensus, operatorWhitelistProbe = operatorWhitelistResult, domainReachability = domainReachability, + verdictDecision = verdictDecision, + diagnosticSnapshot = diagnosticSnapshot, ) } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt new file mode 100644 index 0000000..8d5492e --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorChecker.kt @@ -0,0 +1,190 @@ +package com.notcvnt.rknhardering.checker + +import android.content.Context +import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource +import com.notcvnt.rknhardering.model.Finding +import com.notcvnt.rknhardering.probe.NativeSignsBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Deep VPN detector ported from the reference [dev.soranerai.vpndetector] APK. + * Lives in its own UI category, separate from the legacy [NativeSignsChecker]. + * Every native line is prefixed with "vdet|" and has the shape "vdet||". + */ +object VpnNativeDetectorChecker { + + private val NETWORK_KINDS = setOf( + "fib_trie_denied", "inet_diag_denied", "bindtodevice_leak", + "getsockname_leak", "udp_port_conflict_physical", "vpn_qdisc", + "bpf_map_accessible", "route_count", "trim_oracle", + ) + + private val INDIRECT_KINDS = setOf( + "pmtu_mss_combined", "udp_pmtu_ok", "udp_pmtu_fail", "normal_pmtu", + "timing_oracle", "backpressure", "gso_failed", "gso_send_failed", + "gso_ok", "hw_timestamp", + ) + + private val HIGH_CONFIDENCE = setOf( + "sysfs_vpn_leak", "getifaddrs_vpn", "sysclassnet_vpn", "rtm_getlink_vpn", + "proc_if_inet6_vpn", "proc_ipv6_route_vpn", "proc_net_dev_vpn", + "ifindexname_vpn", "vpn_policy_rules_netlink", + "bindtodevice_leak", "getsockname_leak", "udp_port_conflict_physical", + ) + + private val INFORMATIONAL_KINDS = setOf( + "route_count", "pmtu_mss_combined", "udp_pmtu_ok", "normal_pmtu", + "timing_oracle", "backpressure", "gso_failed", "gso_send_failed", + "gso_ok", "hw_timestamp", + ) + + private fun libraryUnavailableResult(context: Context): CategoryResult { + val loadError = NativeSignsBridge.lastLoadErrorMessage() + val description = if (loadError != null) { + context.getString(R.string.checker_vpn_detector_unavailable_with_reason, loadError) + } else { + context.getString(R.string.checker_vpn_detector_unavailable) + } + return CategoryResult( + name = context.getString(R.string.checker_vpn_detector_category_name), + detected = false, + findings = listOf(Finding(description = description, isInformational = true)), + needsReview = false, + evidence = emptyList(), + ) + } + + private fun mapSource(kind: String): EvidenceSource = when (kind) { + in NETWORK_KINDS -> EvidenceSource.NATIVE_SOCKET + in INDIRECT_KINDS -> EvidenceSource.NATIVE_ROUTE + else -> EvidenceSource.NATIVE_INTERFACE + } + + private fun evaluateItem( + context: Context, + item: ParsedRow, + findings: MutableList, + evidence: MutableList, + ) { + val description = describe(context, item.kind, item.detail) + val source = mapSource(item.kind) + if (item.kind in INFORMATIONAL_KINDS) { + findings += Finding( + description = description, + isInformational = true, + source = source, + confidence = EvidenceConfidence.LOW, + ) + return + } + + val isHigh = item.kind in HIGH_CONFIDENCE + val confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM + findings += Finding( + description = description, + detected = isHigh, + needsReview = !isHigh, + source = source, + confidence = confidence, + ) + evidence += EvidenceItem( + source = source, + detected = true, + confidence = confidence, + description = description, + ) + } + + suspend fun check(context: Context): CategoryResult = withContext(Dispatchers.IO) { + val executionContext = ScanExecutionContext.currentOrDefault() + executionContext.throwIfCancelled() + NativeSignsBridge.initIfNeeded() + if (!NativeSignsBridge.isLibraryLoaded()) return@withContext libraryUnavailableResult(context) + + val rows = runCatching { + NativeSignsBridge.detectVpnDetector(executionContext.cancellationSignal) + }.getOrDefault(emptyArray()) + executionContext.throwIfCancelled() + val parsed = rows.mapNotNull { parseRow(it) } + + val findings = mutableListOf() + val evidence = mutableListOf() + + for (item in parsed) { + evaluateItem(context, item, findings, evidence) + } + + if (findings.isEmpty()) { + findings += Finding( + description = context.getString(R.string.checker_vpn_detector_no_anomalies), + isInformational = true, + ) + } + + CategoryResult( + name = context.getString(R.string.checker_vpn_detector_category_name), + detected = findings.any { it.detected }, + findings = findings, + needsReview = findings.any { it.needsReview }, + evidence = evidence, + ) + } + + private data class ParsedRow(val kind: String, val detail: String) + + private fun parseRow(row: String): ParsedRow? { + if (!row.startsWith("vdet|")) return null + val body = row.removePrefix("vdet|") + val sep = body.indexOf('|') + if (sep <= 0) return null + val kind = body.substring(0, sep) + val detail = body.substring(sep + 1).takeIf { it.isNotBlank() } ?: return null + return ParsedRow(kind, detail) + } + + private fun describe(context: Context, kind: String, detail: String): String { + val resId = when (kind) { + "sysfs_vpn_leak" -> R.string.vpn_desc_sysfs_leak + "getifaddrs_vpn" -> R.string.vpn_desc_getifaddrs + "sysclassnet_vpn" -> R.string.vpn_desc_sysclassnet + "rtm_getlink_vpn" -> R.string.vpn_desc_rtm_getlink + "proc_if_inet6_vpn" -> R.string.vpn_desc_proc_if_inet6 + "proc_ipv6_route_vpn" -> R.string.vpn_desc_proc_ipv6_route + "proc_net_dev_vpn" -> R.string.vpn_desc_proc_net_dev + "ifindexname_vpn" -> R.string.vpn_desc_ifindexname + "vpn_policy_rules_netlink" -> R.string.vpn_desc_policy_rules_netlink + "fib_trie_denied" -> R.string.vpn_desc_fib_trie + "inet_diag_denied" -> R.string.vpn_desc_inet_diag + "bindtodevice_leak" -> R.string.vpn_desc_bindtodevice_leak + "getsockname_leak" -> R.string.vpn_desc_getsockname + "udp_port_conflict_physical" -> R.string.vpn_desc_udp_port_physical + "vpn_qdisc" -> R.string.vpn_desc_qdisc + "bpf_map_accessible" -> R.string.vpn_desc_bpf + "route_count" -> R.string.vpn_desc_route_count + "trim_oracle" -> R.string.vpn_desc_trim_oracle + "pmtu_mss_combined" -> R.string.vpn_desc_pmtu_mss + "udp_pmtu_ok" -> R.string.vpn_desc_udp_pmtu_ok + "udp_pmtu_fail" -> R.string.vpn_desc_udp_pmtu_fail + "normal_pmtu" -> R.string.vpn_desc_normal_pmtu + "timing_oracle" -> R.string.vpn_desc_timing_oracle + "backpressure" -> R.string.vpn_desc_backpressure + "gso_failed" -> R.string.vpn_desc_gso_failed + "gso_send_failed" -> R.string.vpn_desc_gso_send_failed + "gso_ok" -> R.string.vpn_desc_gso_ok + "hw_timestamp" -> R.string.vpn_desc_hw_timestamp + "traceroute_denied" -> R.string.vpn_desc_traceroute + else -> null + } + return if (resId != null) { + "$detail — ${context.getString(resId)}" + } else { + "$kind: $detail" + } + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt b/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt index 2d5a60a..1d67882 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt @@ -8,6 +8,8 @@ import java.security.MessageDigest // for the offline signing CLI under :app:src/test (host-only path). object Ed25519 { + private const val SHA_512 = "SHA-512" + private val P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19)) private val L = BigInteger("7237005577332262213973186563042994240857116359379907606001950938285454250989") private val D = BigInteger("-121665").mod(P) @@ -26,7 +28,7 @@ object Ed25519 { val s = decodeScalarLE(signature.copyOfRange(32, 64)) if (s >= L) return false val rPoint = runCatching { decodePoint(r) }.getOrNull() ?: return false - val md = MessageDigest.getInstance("SHA-512") + val md = MessageDigest.getInstance(SHA_512) md.update(r) md.update(publicKey) md.update(message) @@ -38,7 +40,7 @@ object Ed25519 { fun sign(privateKey: ByteArray, publicKey: ByteArray, message: ByteArray): ByteArray { require(privateKey.size == 32 && publicKey.size == 32) - val md = MessageDigest.getInstance("SHA-512") + val md = MessageDigest.getInstance(SHA_512) val h = md.digest(privateKey) val a = clampScalar(h.copyOfRange(0, 32)) val prefix = h.copyOfRange(32, 64) @@ -55,7 +57,7 @@ object Ed25519 { fun derivePublicKey(privateKey: ByteArray): ByteArray { require(privateKey.size == 32) - val h = MessageDigest.getInstance("SHA-512").digest(privateKey) + val h = MessageDigest.getInstance(SHA_512).digest(privateKey) val a = clampScalar(h.copyOfRange(0, 32)) return encodePoint(scalarMul(B, a)) } @@ -92,9 +94,7 @@ object Ed25519 { if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) { x = x.multiply(I).mod(P) } - if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) { - throw IllegalArgumentException("no sqrt") - } + require(x.multiply(x).subtract(xx).mod(P) == BigInteger.ZERO) { "no sqrt" } if ((x.testBit(0)) != sign) x = P.subtract(x).mod(P) return x } @@ -105,10 +105,10 @@ object Ed25519 { val sign = (raw[31].toInt() and 0x80) != 0 raw[31] = (raw[31].toInt() and 0x7F).toByte() val y = decodeScalarLE(raw) - if (y >= P) throw IllegalArgumentException("y out of range") + require(y < P) { "y out of range" } val x = recoverX(y, sign) val p = Point(x, y) - if (!onCurve(p)) throw IllegalArgumentException("not on curve") + require(onCurve(p)) { "not on curve" } return p } diff --git a/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt b/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt index 9ad7b8b..2f287ce 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/customcheck/mapper/EndpointResponseMapper.kt @@ -83,9 +83,9 @@ object EndpointResponseMapper { fun autoDetectResponseType(rawResponse: String): ResponseType { val trimmed = rawResponse.trim() // 1. Try JSON object - try { JSONObject(trimmed); return ResponseType.JSON } catch (_: Exception) { } + try { JSONObject(trimmed); return ResponseType.JSON } catch (_: Exception) { /* not a JSON object, try next format */ } // 2. Try JSON array - try { JSONArray(trimmed); return ResponseType.JSON } catch (_: Exception) { } + try { JSONArray(trimmed); return ResponseType.JSON } catch (_: Exception) { /* not a JSON array, try next format */ } // 3. key=value lines val lines = trimmed.lines().filter { it.isNotBlank() } val kvPattern = Regex("""^\w+=.+$""") @@ -180,9 +180,12 @@ object EndpointResponseMapper { if (countryCodePath == null && key in setOf("country_code", "cc", "countrycode", "country") && COUNTRY_CODE_REGEX.matches(strVal)) { countryCodePath = path } - if (countryNamePath == null && key in setOf("country", "country_name", "countryname")) { - // only if it doesn't look like a 2-letter code itself - if (!COUNTRY_CODE_REGEX.matches(strVal)) countryNamePath = path + // only if it doesn't look like a 2-letter code itself + if (countryNamePath == null && + key in setOf("country", "country_name", "countryname") && + !COUNTRY_CODE_REGEX.matches(strVal) + ) { + countryNamePath = path } if (ispPath == null && key in setOf("isp", "provider")) { ispPath = path diff --git a/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt b/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt index c75df27..757d9c2 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/customcheck/ui/CheckerSectionController.kt @@ -80,7 +80,7 @@ internal class CheckerSectionController( updateNoteVisibility() } - fun isExpanded(): Boolean = expanded + val isExpanded: Boolean get() = expanded fun setEnabled(value: Boolean, propagate: Boolean) { enabled = value diff --git a/app/src/main/java/com/notcvnt/rknhardering/diagnostics/DiagnosticResultRecorder.kt b/app/src/main/java/com/notcvnt/rknhardering/diagnostics/DiagnosticResultRecorder.kt new file mode 100644 index 0000000..aaf2e67 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/diagnostics/DiagnosticResultRecorder.kt @@ -0,0 +1,242 @@ +package com.notcvnt.rknhardering.diagnostics + +import com.notcvnt.rknhardering.model.BypassResult +import com.notcvnt.rknhardering.model.CdnPullingResult +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.DomainReachabilityResult +import com.notcvnt.rknhardering.model.IpComparisonResult +import com.notcvnt.rknhardering.model.IpConsensusResult +import com.notcvnt.rknhardering.model.VerdictDecision + +object DiagnosticResultRecorder { + fun recordCategory( + collector: DiagnosticTraceCollector?, + category: String, + source: String, + result: CategoryResult, + ) { + collector ?: return + collector.record( + category = category, + source = source, + status = status(result.detected, result.needsReview, result.hasError), + body = buildString { + appendLine("name=${result.name}") + appendLine("detected=${result.detected}") + appendLine("needsReview=${result.needsReview}") + appendLine("hasError=${result.hasError}") + result.geoFacts?.let { appendLine("geoFacts=$it") } + result.locationFacts?.let { appendLine("locationFacts=$it") } + result.evidence.forEachIndexed { index, item -> + appendLine( + "evidence[$index]=source:${item.source},detected:${item.detected}," + + "confidence:${item.confidence},description:${item.description}", + ) + } + result.findings.forEachIndexed { index, finding -> + appendLine( + "finding[$index]=detected:${finding.detected},needsReview:${finding.needsReview}," + + "informational:${finding.isInformational},error:${finding.isError}," + + "source:${finding.source},description:${finding.description}", + ) + } + result.stunProbeGroups.flatMap { it.results }.forEachIndexed { index, item -> + appendLine( + "stun[$index]=host:${item.host},port:${item.port},scope:${item.scope}," + + "mappedIpv4:${item.mappedIpv4},mappedIpv6:${item.mappedIpv6},error:${item.error}", + ) + } + result.callTransportLeaks.forEachIndexed { index, item -> + appendLine( + "callTransport[$index]=service:${item.service},kind:${item.probeKind}," + + "path:${item.networkPath},status:${item.status},target:${item.targetHost}:${item.targetPort}," + + "resolved:${item.resolvedIps},mapped:${item.mappedIp},public:${item.observedPublicIp}," + + "summary:${item.summary}", + ) + } + }, + ) + result.geoIpResponses.forEach { response -> + collector.record( + category = category, + source = "${response.provider} HTTP", + status = if (response.error == null) "completed" else "error", + body = buildString { + appendLine("ip=${response.ip}") + appendLine("error=${response.error}") + append(response.rawBody.orEmpty()) + }, + ) + } + } + + fun recordIpComparison( + collector: DiagnosticTraceCollector?, + result: IpComparisonResult, + ) { + collector ?: return + collector.record( + category = "ipc", + source = "IpComparisonChecker", + status = status(result.detected, result.needsReview, result.hasError), + body = buildString { + appendLine("summary=${result.summary}") + (result.ruGroup.responses + result.nonRuGroup.responses).forEachIndexed { index, response -> + appendLine( + "response[$index]=label:${response.label},url:${response.url},scope:${response.scope}," + + "ip:${response.ip},ipv4Records:${response.ipv4Records}," + + "ipv6Records:${response.ipv6Records},error:${response.error}", + ) + } + }, + ) + } + + fun recordCdn(collector: DiagnosticTraceCollector?, result: CdnPullingResult) { + collector ?: return + collector.record( + category = "cdn", + source = "CdnPullingChecker", + status = status(result.detected, result.needsReview, result.hasError), + body = "summary=${result.summary}", + ) + result.responses.forEach { response -> + collector.record( + category = "cdn", + source = response.targetLabel, + target = response.url, + status = if (response.error == null) "completed" else "error", + body = buildString { + appendLine("ip=${response.ip}") + appendLine("ipv4=${response.ipv4}") + appendLine("ipv6=${response.ipv6}") + appendLine("importantFields=${response.importantFields}") + appendLine("error=${response.error}") + append(response.rawBody.orEmpty()) + }, + ) + } + } + + fun recordBypass(collector: DiagnosticTraceCollector?, result: BypassResult) { + collector ?: return + collector.record( + category = "byp", + source = "BypassChecker", + status = status(result.detected, result.needsReview, result.hasError), + body = buildString { + appendLine("proxyEndpoint=${result.proxyEndpoint}") + appendLine("directIp=${result.directIp}") + appendLine("proxyIp=${result.proxyIp}") + appendLine("vpnNetworkIp=${result.vpnNetworkIp}") + appendLine("underlyingIp=${result.underlyingIp}") + appendLine("proxyChecks=${result.proxyChecks}") + result.evidence.forEachIndexed { index, item -> + appendLine("evidence[$index]=source:${item.source},detected:${item.detected},confidence:${item.confidence},description:${item.description}") + } + result.findings.forEachIndexed { index, item -> + appendLine("finding[$index]=detected:${item.detected},review:${item.needsReview},error:${item.isError},description:${item.description}") + } + }, + ) + result.xrayApiScanResult?.let { xray -> + collector.record( + category = "byp", + source = "Xray API", + target = "${xray.endpoint.host}:${xray.endpoint.port}", + status = if (xray.handlerAvailable) "available" else "unavailable", + body = buildString { + appendLine("outboundCount=${xray.outbounds.size}") + xray.outbounds.forEachIndexed { index, outbound -> + appendLine( + "outbound[$index]=tag:${outbound.tag},protocol:${outbound.protocolName}," + + "address:${outbound.address},port:${outbound.port},sni:${outbound.sni}," + + "uuidPresent:${!outbound.uuid.isNullOrBlank()}," + + "publicKeyPresent:${!outbound.publicKey.isNullOrBlank()}," + + "senderType:${outbound.senderSettingsType},proxyType:${outbound.proxySettingsType}", + ) + } + }, + ) + } + result.clashApiScanResult?.let { clash -> + collector.record( + category = "byp", + source = "Clash API", + target = "${clash.endpoint.host}:${clash.endpoint.port}", + status = if (clash.configAvailable) "available" else "unavailable", + body = buildString { + appendLine("configAvailable=${clash.configAvailable}") + appendLine("destinationIpCount=${clash.leakedDestIps.size}") + appendLine("proxyNodeCount=${clash.proxyNodes.size}") + }, + ) + } + } + + fun recordDomainReachability( + collector: DiagnosticTraceCollector?, + result: DomainReachabilityResult, + ) { + collector ?: return + result.responses.forEach { response -> + collector.record( + category = "rea", + source = "DomainReachabilityChecker", + target = response.domain, + status = if (response.matchesExpectation) "expected" else "mismatch", + body = buildString { + appendLine("label=${response.label}") + appendLine("dns=${response.dnsStatus},error=${response.dnsError},resolved=${response.resolvedIps}") + appendLine("tcp=${response.tcpStatus},error=${response.tcpError}") + appendLine("tls=${response.tlsStatus},error=${response.tlsError}") + appendLine( + "expectedDns=${response.expectedDnsAvailable}," + + "expectedTcp=${response.expectedTcpAvailable},expectedTls=${response.expectedTlsAvailable}", + ) + }, + ) + } + } + + fun recordConsensus(collector: DiagnosticTraceCollector?, result: IpConsensusResult) { + collector?.record( + category = "ipc", + source = "IpConsensusBuilder", + status = if (result.needsReview) "review" else "completed", + body = buildString { + appendLine("observedIps=${result.observedIps}") + appendLine("unparsedIps=${result.unparsedIps}") + appendLine("channelIps=${result.channelIps}") + appendLine("channelConflict=${result.channelConflict}") + appendLine("crossChannelMismatch=${result.crossChannelMismatch}") + appendLine("foreignIps=${result.foreignIps}") + appendLine("geoCountryMismatch=${result.geoCountryMismatch}") + appendLine("warpLikeIndicator=${result.warpLikeIndicator}") + appendLine("probeTargetDivergence=${result.probeTargetDivergence}") + appendLine("probeTargetDirectDivergence=${result.probeTargetDirectDivergence}") + }, + ) + } + + fun recordVerdict(collector: DiagnosticTraceCollector?, decision: VerdictDecision) { + collector?.record( + category = "geo", + source = "VerdictEngine", + status = decision.verdict.name, + body = buildString { + appendLine("rule=${decision.ruleCode}") + decision.participants.forEachIndexed { index, participant -> + appendLine("participant[$index]=factor:${participant.factor},sources:${participant.evidenceSources}") + } + }, + ) + } + + private fun status(detected: Boolean, review: Boolean, error: Boolean): String = when { + error -> "error" + detected -> "detected" + review -> "review" + else -> "clean" + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/diagnostics/DiagnosticTrace.kt b/app/src/main/java/com/notcvnt/rknhardering/diagnostics/DiagnosticTrace.kt new file mode 100644 index 0000000..f3fa53b --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/diagnostics/DiagnosticTrace.kt @@ -0,0 +1,179 @@ +package com.notcvnt.rknhardering.diagnostics + +import java.nio.charset.StandardCharsets + +data class DiagnosticEntry( + val category: String, + val source: String, + val target: String?, + val status: String, + val durationMs: Long?, + val body: String, + val storedBytes: Int, + val originalBytes: Int, + val truncated: Boolean, +) { + override fun toString(): String = + "DiagnosticEntry(category=$category, source=$source, target=$target, status=$status, " + + "durationMs=$durationMs, storedBytes=$storedBytes, originalBytes=$originalBytes, truncated=$truncated)" +} + +data class DiagnosticSnapshot( + val entries: List, + val storedBytes: Int, + val truncated: Boolean, +) { + companion object { + val EMPTY = DiagnosticSnapshot(emptyList(), storedBytes = 0, truncated = false) + } +} + +class DiagnosticTraceCollector( + private val privacyMode: Boolean, + private val entryLimitBytes: Int = DEFAULT_ENTRY_LIMIT_BYTES, + private val runLimitBytes: Int = DEFAULT_RUN_LIMIT_BYTES, +) { + private val lock = Any() + private val entries = mutableListOf() + private var storedBytes = 0 + private var closed = false + private var runTruncated = false + + fun record( + category: String, + source: String, + target: String? = null, + status: String, + durationMs: Long? = null, + body: String = "", + ) { + val safeCategory = DiagnosticSanitizer.sanitize(category, privacyMode) + val safeSource = DiagnosticSanitizer.sanitize(source, privacyMode) + val safeTarget = target?.let { DiagnosticSanitizer.sanitize(it, privacyMode) } + val safeStatus = DiagnosticSanitizer.sanitize(status, privacyMode) + val safeBody = DiagnosticSanitizer.sanitize(body, privacyMode) + val metadataBytes = listOfNotNull(safeCategory, safeSource, safeTarget, safeStatus) + .sumOf { it.toByteArray(StandardCharsets.UTF_8).size } + val originalBodyBytes = safeBody.toByteArray(StandardCharsets.UTF_8).size + val originalBytes = metadataBytes + originalBodyBytes + + synchronized(lock) { + if (closed) return + val available = (runLimitBytes - storedBytes).coerceAtLeast(0) + val allowed = minOf(entryLimitBytes.coerceAtLeast(0), available) + if (allowed < metadataBytes) { + runTruncated = true + return + } + val storedBody = truncateUtf8(safeBody, allowed - metadataBytes) + val bodyBytes = storedBody.toByteArray(StandardCharsets.UTF_8).size + val entryBytes = metadataBytes + bodyBytes + val truncated = bodyBytes < originalBodyBytes + storedBytes += entryBytes + runTruncated = runTruncated || truncated + entries += DiagnosticEntry( + category = safeCategory, + source = safeSource, + target = safeTarget, + status = safeStatus, + durationMs = durationMs?.coerceAtLeast(0), + body = storedBody, + storedBytes = entryBytes, + originalBytes = originalBytes, + truncated = truncated, + ) + } + } + + fun snapshot(): DiagnosticSnapshot = synchronized(lock) { + closed = true + DiagnosticSnapshot( + entries = entries.sortedWith( + compareBy( + DiagnosticEntry::category, + DiagnosticEntry::source, + { it.target.orEmpty() }, + DiagnosticEntry::status, + DiagnosticEntry::body, + ), + ), + storedBytes = storedBytes, + truncated = runTruncated, + ) + } + + fun clear() = synchronized(lock) { + entries.clear() + storedBytes = 0 + runTruncated = false + closed = true + } + + companion object { + const val DEFAULT_ENTRY_LIMIT_BYTES = 64 * 1024 + const val DEFAULT_RUN_LIMIT_BYTES = 512 * 1024 + + private fun truncateUtf8(value: String, maxBytes: Int): String { + if (maxBytes <= 0 || value.isEmpty()) return "" + if (value.toByteArray(StandardCharsets.UTF_8).size <= maxBytes) return value + var used = 0 + var index = 0 + while (index < value.length) { + val codePoint = value.codePointAt(index) + val charCount = Character.charCount(codePoint) + val byteCount = String(Character.toChars(codePoint)) + .toByteArray(StandardCharsets.UTF_8) + .size + if (used + byteCount > maxBytes) break + used += byteCount + index += charCount + } + return value.substring(0, index) + } + } +} + +object DiagnosticSanitizer { + private const val REDACTED = "[REDACTED]" + private const val IPV4_REDACTED = "[IPv4 redacted]" + private const val IPV6_REDACTED = "[IPv6 redacted]" + + private val sensitiveHeader = Regex( + "(?im)^(\\s*)(authorization|proxy-authorization|cookie|set-cookie)(\\s*:\\s*).*$", + ) + private val bearerOrBasic = Regex("(?i)\\b(Bearer|Basic)\\s+[A-Za-z0-9._~+/=-]+") + private val sensitiveQuery = Regex( + "(?i)([?&](?:token|key|api[_-]?key|password|secret|auth|authorization|signature|session|cookie)=)[^&#\\s]+", + ) + private val sensitiveQuotedAssignment = Regex( + "(?i)([\\\"']?(?:authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|key|secret|private[_-]?key|public[_-]?key|uuid|bssid|cell[_-]?(?:id|identity))[\\\"']?\\s*[:=]\\s*)([\\\"'])(.*?)\\2", + ) + private val sensitiveAssignment = Regex( + "(?i)([\\\"']?(?:authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|key|secret|private[_-]?key|public[_-]?key|uuid|bssid|cell[_-]?(?:id|identity))[\\\"']?\\s*[:=]\\s*)([\\\"']?)[^,;\\s&}\\\"']+([\\\"']?)", + ) + private val uriUserInfo = Regex("(?i)([a-z][a-z0-9+.-]*://)[^/@\\s]+@") + private val uuid = Regex("(?i)\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b") + private val pem = Regex("(?s)-----BEGIN [^-]+-----.*?-----END [^-]+-----") + private val macAddress = Regex("(?i)\\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\\b") + private val ipv4 = Regex("(? + "${match.groupValues[1]}${match.groupValues[2]}${match.groupValues[3]}$REDACTED" + } + safe = bearerOrBasic.replace(safe) { "${it.groupValues[1]} $REDACTED" } + safe = sensitiveQuery.replace(safe) { "${it.groupValues[1]}$REDACTED" } + safe = sensitiveQuotedAssignment.replace(safe) { "${it.groupValues[1]}$REDACTED" } + safe = sensitiveAssignment.replace(safe) { "${it.groupValues[1]}$REDACTED" } + safe = uriUserInfo.replace(safe) { "${it.groupValues[1]}$REDACTED@" } + safe = uuid.replace(safe, REDACTED) + safe = pem.replace(safe, REDACTED) + safe = macAddress.replace(safe, REDACTED) + if (privacyMode) { + safe = ipv4.replace(safe, IPV4_REDACTED) + safe = ipv6.replace(safe, IPV6_REDACTED) + } + return safe + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt b/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt index 5bce9e8..d05d44b 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt @@ -287,6 +287,31 @@ enum class Verdict { DETECTED, } +enum class VerdictRuleCode(val code: String) { + R1_HARD_BYPASS("R1"), + R3_PROBE_TARGET_DIVERGENCE("R3"), + R3_PROBE_TARGET_DIRECT_DIVERGENCE("R3"), + R3_CROSS_CHANNEL_MISMATCH("R3"), + R4_LOCATION_GEO_CONFLICT("R4"), + R4_HOSTING_REVIEW("R4"), + R5_MATRIX("R5"), + R6_FALLBACK("R6"), + UNSPECIFIED("unknown"), +} + +data class VerdictParticipant( + val factor: String, + val evidenceSources: Set = emptySet(), +) + +data class VerdictDecision( + val verdict: Verdict, + val rule: VerdictRuleCode, + val participants: List, +) { + val ruleCode: String get() = rule.code +} + data class BypassResult( val proxyEndpoint: ProxyEndpoint?, val proxyOwner: LocalProxyOwner? = null, @@ -472,4 +497,10 @@ data class CheckResult( val customProfileId: String? = null, val customProfileName: String? = null, val domainReachability: DomainReachabilityResult = DomainReachabilityResult.empty(), + val verdictDecision: VerdictDecision = VerdictDecision( + verdict = verdict, + rule = VerdictRuleCode.UNSPECIFIED, + participants = emptyList(), + ), + val diagnosticSnapshot: com.notcvnt.rknhardering.diagnostics.DiagnosticSnapshot? = null, ) diff --git a/app/src/main/java/com/notcvnt/rknhardering/network/NetworkInterfacePatterns.kt b/app/src/main/java/com/notcvnt/rknhardering/network/NetworkInterfacePatterns.kt index 4f04d87..f496d3a 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/network/NetworkInterfacePatterns.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/network/NetworkInterfacePatterns.kt @@ -17,6 +17,14 @@ object NetworkInterfacePatterns { val IPSEC_INTERFACE_PATTERN: Regex = Regex("^(ipsec|xfrm).*") + private val CELLULAR_MODEM_INTERFACES: List = listOf( + Regex("^rmnet.*"), + Regex("^ccmni.*"), + Regex("^ccemni.*"), + Regex("^pdp.*"), + Regex("^seth.*"), + ) + val STANDARD_INTERFACES: List = listOf( Regex("^wlan.*"), Regex("^rmnet.*"), @@ -58,4 +66,10 @@ object NetworkInterfacePatterns { if (canonical.isNullOrBlank()) return false return STANDARD_INTERFACES.any { it.matches(canonical) } } + + fun isCellularModemInterface(name: String?): Boolean { + val canonical = NetworkInterfaceNameNormalizer.canonicalName(name) + if (canonical.isNullOrBlank()) return false + return CELLULAR_MODEM_INTERFACES.any { it.matches(canonical) } + } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt b/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt index 1aa0420..6367d77 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/network/ResolverBinding.kt @@ -68,19 +68,13 @@ internal object ResolverSocketBinder { private fun bindSocketToDevice(socket: Socket, interfaceName: String) { bindSocketToDeviceOverride?.invoke(socket, interfaceName) ?: ParcelFileDescriptor.fromSocket(socket).use { pfd -> - bindFileDescriptorToDevice( - pfd.fileDescriptor, - interfaceName, - ) + bindFileDescriptorToDevice(pfd.fileDescriptor, interfaceName) } } private fun bindDatagramToDevice(socket: DatagramSocket, interfaceName: String) { bindDatagramToDeviceOverride?.invoke(socket, interfaceName) ?: ParcelFileDescriptor.fromDatagramSocket(socket).use { pfd -> - bindFileDescriptorToDevice( - pfd.fileDescriptor, - interfaceName, - ) + bindFileDescriptorToDevice(pfd.fileDescriptor, interfaceName) } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/network/ResolverNetworkStack.kt b/app/src/main/java/com/notcvnt/rknhardering/network/ResolverNetworkStack.kt index e25e049..2ded6a7 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/network/ResolverNetworkStack.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/network/ResolverNetworkStack.kt @@ -73,8 +73,33 @@ object ResolverNetworkStack { cancellationSignal: ScanCancellationSignal? = null, ): List { literalToInetAddress(hostname)?.let { return listOf(it) } + val startedAt = System.nanoTime() + val executionContext = cancellationSignal?.let { signal -> + ScanExecutionContext.currentOrDefault().takeIf { it.cancellationSignal === signal } + } ?: ScanExecutionContext.currentOrDefault() cancellationSignal?.throwIfCancelled() - return dns(config, binding, cancellationSignal).lookup(hostname) + return try { + dns(config, binding, cancellationSignal).lookup(hostname).also { addresses -> + executionContext.diagnosticCollector?.record( + category = "ind", + source = "DNS", + target = hostname, + status = "resolved", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = addresses.mapNotNull { it.hostAddress }.distinct().joinToString("\n"), + ) + } + } catch (error: Exception) { + executionContext.diagnosticCollector?.record( + category = "ind", + source = "DNS", + target = hostname, + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = error.message.orEmpty(), + ) + throw error + } } internal fun resetForTests() { @@ -118,7 +143,47 @@ object ResolverNetworkStack { nativeCurlRetryCount = nativeCurlRetryCount, cancellationSignal = cancellationSignal, ) - return executeWithFallback(request) + val startedAt = System.nanoTime() + return try { + executeWithFallback(request).also { response -> + currentExecutionContext(request).diagnosticCollector?.record( + category = diagnosticCategoryForUrl(url), + source = "${method.uppercase()} HTTP", + target = url, + status = "HTTP ${response.code}", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = response.body, + ) + } + } catch (error: Exception) { + currentExecutionContext(request).diagnosticCollector?.record( + category = diagnosticCategoryForUrl(url), + source = "${method.uppercase()} HTTP", + target = url, + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = error.message.orEmpty(), + ) + throw error + } + } + + private fun diagnosticCategoryForUrl(url: String): String { + val normalized = url.lowercase() + return when { + normalized.contains("googlevideo.com") || + normalized.contains("cloudflare.com") || + normalized.contains("one.one.one.one") || + normalized.contains("rutracker.org") || + normalized.contains("meduza.io") -> "cdn" + normalized.contains("ifconfig") || + normalized.contains("ipify") || + normalized.contains("ip.sb") || + normalized.contains("checkip.amazonaws") || + normalized.contains("ip.mail.ru") || + normalized.contains("internet.yandex") -> "ipc" + else -> "geo" + } } private fun executeWithFallback(request: ResolverHttpRequest): ResolverHttpResponse { diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/ClashApiScanner.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/ClashApiScanner.kt index 58fc84d..1e04c00 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/ClashApiScanner.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/ClashApiScanner.kt @@ -56,9 +56,21 @@ class ClashApiScanner( private suspend fun probeApi(host: String, port: Int): ClashApiScanResult? { probeApiOverride?.let { return it(host, port) } + val executionContext = ScanExecutionContext.currentOrDefault() + val startedAt = System.nanoTime() val client = clients.getValue(host) val configs = client.fetchConfigs(port) - if (!ClashApiClient.isConfigResponseAlive(configs)) return null + if (!ClashApiClient.isConfigResponseAlive(configs)) { + executionContext.diagnosticCollector?.record( + category = "byp", + source = "Clash API", + target = "$host:$port", + status = "unavailable", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = "configAvailable=false", + ) + return null + } val connections = client.fetchConnections(port) val proxies = client.fetchProxies(port) return ClashApiScanResult( @@ -66,7 +78,21 @@ class ClashApiScanner( leakedDestIps = ClashApiClient.parseConnectionsDestinationIps(connections), proxyNodes = ClashApiClient.parseProxyNodes(proxies), configAvailable = true, - ) + ).also { result -> + // Never retain the full /configs, /connections or /proxies bodies. + executionContext.diagnosticCollector?.record( + category = "byp", + source = "Clash API", + target = "$host:$port", + status = "available", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = buildString { + appendLine("configAvailable=true") + appendLine("destinationIpCount=${result.leakedDestIps.size}") + appendLine("proxyNodeCount=${result.proxyNodes.size}") + }, + ) + } } private fun isTcpPortOpen(host: String, port: Int): Boolean { diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/MtProtoProber.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/MtProtoProber.kt index 1d79ce9..d65f3ff 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/MtProtoProber.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/MtProtoProber.kt @@ -45,6 +45,7 @@ object MtProtoProber { readTimeoutMs: Int = 3000, executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(), ): ProbeResult = withContext(Dispatchers.IO) { + val startedAt = System.nanoTime() for (target in TELEGRAM_DC_TARGETS) { val reachable = trySocks5Connect( proxyHost = proxyHost, @@ -56,10 +57,31 @@ object MtProtoProber { executionContext = executionContext, ) if (reachable) { - return@withContext ProbeResult(reachable = true, targetAddress = target) + return@withContext ProbeResult(reachable = true, targetAddress = target).also { result -> + recordDiagnostic(executionContext, proxyHost, proxyPort, startedAt, result) + } } } - ProbeResult(reachable = false, targetAddress = null) + ProbeResult(reachable = false, targetAddress = null).also { result -> + recordDiagnostic(executionContext, proxyHost, proxyPort, startedAt, result) + } + } + + private fun recordDiagnostic( + executionContext: ScanExecutionContext, + proxyHost: String, + proxyPort: Int, + startedAt: Long, + result: ProbeResult, + ) { + executionContext.diagnosticCollector?.record( + category = "byp", + source = "MTProto proxy probe", + target = "$proxyHost:$proxyPort", + status = if (result.reachable) "reachable" else "unreachable", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = "target=${result.targetAddress}", + ) } /** diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt index ecd140a..87394c0 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbe.kt @@ -42,6 +42,7 @@ data class NativeRouteEntry( val type: String? = null, val table: Int? = null, val prefixLen: Int? = null, + val protocol: Int? = null, ) { enum class RouteSource { PROC, NETLINK } } @@ -69,7 +70,32 @@ data class NativeEmulatorFinding( val detail: String?, ) +data class NativeVpnPropertyFinding( + val kind: String, + val prop: String?, + val value: String?, +) + +data class NativeVpnLeakFinding( + val kind: String, + val detail: String?, + val count: Int = 0, +) + +data class NativeVpnAdvancedFinding( + val kind: String, + val detail: String?, + val count: Int = 0, +) + +data class NativeVpnSyscallFinding( + val kind: String, + val detail: String?, +) + object NativeInterfaceProbe { + private const val IPV4_ADDRESS_FAMILY = 2 + private const val IPV6_ADDRESS_FAMILY = 10 private const val IPV4_DEFAULT_DESTINATION = "00000000" private const val IPV6_DEFAULT_DESTINATION = "00000000000000000000000000000000" private const val IPV6_DEFAULT_PREFIX_LENGTH = "00" @@ -181,13 +207,14 @@ object NativeInterfaceProbe { var scope: String? = null var type: String? = null var table: Int? = null + var protocol: Int? = null for (token in tokens) { val eq = token.indexOf('=') if (eq <= 0) continue val key = token.substring(0, eq) val value = token.substring(eq + 1) when (key) { - "family" -> family = value.toIntOrNull() ?: 0 + "family" -> family = parseNetlinkFamily(value) "dst" -> { val slash = value.indexOf('/') if (slash > 0) { @@ -205,6 +232,7 @@ object NativeInterfaceProbe { "scope" -> scope = value "type" -> type = value "table" -> table = value.toIntOrNull() + "proto" -> protocol = value.toIntOrNull() } } val iface = dev ?: oif?.let { "if$it" } ?: continue @@ -229,12 +257,19 @@ object NativeInterfaceProbe { type = type, table = table, prefixLen = prefixLen, + protocol = protocol, ), ) } return result } + private fun parseNetlinkFamily(value: String): Int = when (value) { + "inet" -> IPV4_ADDRESS_FAMILY + "inet6" -> IPV6_ADDRESS_FAMILY + else -> value.toIntOrNull() ?: 0 + } + fun collectNetlinkRoutes(): List { val rows = runCatching { NativeSignsBridge.netlinkRouteDump(0) }.getOrDefault(emptyArray()) return parseNetlinkRoutes(rows) @@ -383,4 +418,87 @@ object NativeInterfaceProbe { val rows = NativeSignsBridge.detectEmulator() return parseEmulatorFindings(rows) } + + fun parseVpnPropertyFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val rest = row.substring(sep + 1) + val eq = rest.indexOf('=') + if (eq <= 0) { + NativeVpnPropertyFinding(kind = kind, prop = rest, value = null) + } else { + NativeVpnPropertyFinding( + kind = kind, + prop = rest.substring(0, eq), + value = rest.substring(eq + 1).takeIf { it.isNotBlank() }, + ) + } + } + } + + fun collectVpnPropertyFindings(): List { + val rows = NativeSignsBridge.detectVpnProperties() + return parseVpnPropertyFindings(rows) + } + + fun parseVpnLeakFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val rest = row.substring(sep + 1) + val sep2 = rest.lastIndexOf('|') + if (sep2 > 0) { + val detail = rest.substring(0, sep2) + val count = rest.substring(sep2 + 1).toIntOrNull() ?: 0 + NativeVpnLeakFinding(kind = kind, detail = detail, count = count) + } else { + NativeVpnLeakFinding(kind = kind, detail = rest) + } + } + } + + fun collectVpnLeakFindings(): List { + val rows = NativeSignsBridge.detectVpnLeaks() + return parseVpnLeakFindings(rows) + } + + fun parseVpnAdvancedFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val rest = row.substring(sep + 1) + val sep2 = rest.lastIndexOf('|') + if (sep2 > 0) { + val detail = rest.substring(0, sep2) + val count = rest.substring(sep2 + 1).toIntOrNull() ?: 0 + NativeVpnAdvancedFinding(kind = kind, detail = detail, count = count) + } else { + NativeVpnAdvancedFinding(kind = kind, detail = rest) + } + } + } + + fun collectVpnAdvancedFindings(): List { + val rows = NativeSignsBridge.detectVpnAdvanced() + return parseVpnAdvancedFindings(rows) + } + + fun parseVpnSyscallFindings(rows: Array): List { + return rows.mapNotNull { row -> + val sep = row.indexOf('|') + if (sep <= 0) return@mapNotNull null + val kind = row.substring(0, sep) + val detail = row.substring(sep + 1).takeIf { it.isNotBlank() } + NativeVpnSyscallFinding(kind = kind, detail = detail) + } + } + + fun collectVpnSyscallFindings(): List { + val rows = NativeSignsBridge.detectVpnSyscalls() + return parseVpnSyscallFindings(rows) + } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt index 42c88c6..9f85dc0 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/NativeSignsBridge.kt @@ -1,5 +1,8 @@ package com.notcvnt.rknhardering.probe +import com.notcvnt.rknhardering.ScanCancellationSignal +import com.notcvnt.rknhardering.ScanExecutionContext + object NativeSignsBridge { private const val LIBRARY_NAME = "native_signs_probe" @@ -39,6 +42,21 @@ object NativeSignsBridge { @Volatile internal var detectEmulatorOverride: (() -> Array)? = null + @Volatile + internal var detectVpnPropertiesOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnLeaksOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnAdvancedOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnSyscallsOverride: (() -> Array)? = null + + @Volatile + internal var detectVpnDetectorOverride: ((ScanCancellationSignal?) -> Array)? = null + @Volatile private var initialized = false @@ -76,69 +94,159 @@ object NativeSignsBridge { } fun getIfAddrs(): Array { - getIfAddrsOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeGetIfAddrs() }.getOrDefault(emptyArray()) + return traceArray("getifaddrs") { + getIfAddrsOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeGetIfAddrs() }.getOrDefault(emptyArray()) + } } fun ifNameToIndex(name: String): Int { - ifNameToIndexOverride?.let { return it.invoke(name) } - if (!isLibraryLoaded() || name.isBlank()) return 0 - return runCatching { nativeIfNameToIndex(name) }.getOrDefault(0) + return traceValue("if_nametoindex", name, { + ifNameToIndexOverride?.invoke(name) + ?: if (!isLibraryLoaded() || name.isBlank()) 0 else runCatching { nativeIfNameToIndex(name) }.getOrDefault(0) + }) { it.toString() } } fun readProcFile(path: String, maxBytes: Int = 262_144): String? { - readProcFileOverride?.let { return it.invoke(path, maxBytes) } - if (!isLibraryLoaded()) return null - return runCatching { nativeReadProcFile(path, maxBytes) }.getOrNull() + return traceValue("readProcFile", path, { + val override = readProcFileOverride + if (override != null) { + override.invoke(path, maxBytes) + } else if (!isLibraryLoaded()) { + null + } else { + runCatching { nativeReadProcFile(path, maxBytes) }.getOrNull() + } + }) { it.orEmpty() } } fun readSelfMapsSummary(): Array { - readSelfMapsSummaryOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeReadSelfMapsSummary() }.getOrDefault(emptyArray()) + return traceArray("readSelfMapsSummary") { + readSelfMapsSummaryOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeReadSelfMapsSummary() }.getOrDefault(emptyArray()) + } } fun probeFeatureFlags(): Array { - probeFeatureFlagsOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeProbeFeatureFlags() }.getOrDefault(emptyArray()) + return traceArray("probeFeatureFlags") { + probeFeatureFlagsOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeProbeFeatureFlags() }.getOrDefault(emptyArray()) + } } fun libraryIntegrity(): Array { - libraryIntegrityOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeLibraryIntegrity() }.getOrDefault(emptyArray()) + return traceArray("libraryIntegrity") { + libraryIntegrityOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeLibraryIntegrity() }.getOrDefault(emptyArray()) + } } fun interfaceDump(): Array { - interfaceDumpOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeInterfaceDump() }.getOrDefault(emptyArray()) + return traceArray("interfaceDump") { + interfaceDumpOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeInterfaceDump() }.getOrDefault(emptyArray()) + } } fun netlinkRouteDump(family: Int = 0): Array { - netlinkRouteDumpOverride?.let { return it.invoke(family) } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeNetlinkRouteDump(family) }.getOrDefault(emptyArray()) + return traceArray("netlinkRouteDump", "family=$family") { + netlinkRouteDumpOverride?.invoke(family) + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeNetlinkRouteDump(family) }.getOrDefault(emptyArray()) + } } fun netlinkSockDiag(family: Int, protocol: Int): Array { - netlinkSockDiagOverride?.let { return it.invoke(family, protocol) } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeNetlinkSockDiag(family, protocol) }.getOrDefault(emptyArray()) + return traceArray("netlinkSockDiag", "family=$family,protocol=$protocol") { + netlinkSockDiagOverride?.invoke(family, protocol) + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeNetlinkSockDiag(family, protocol) }.getOrDefault(emptyArray()) + } } fun detectRoot(): Array { - detectRootOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeDetectRoot() }.getOrDefault(emptyArray()) + return traceArray("detectRoot") { + detectRootOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectRoot() }.getOrDefault(emptyArray()) + } } fun detectEmulator(): Array { - detectEmulatorOverride?.let { return it.invoke() } - if (!isLibraryLoaded()) return emptyArray() - return runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray()) + return traceArray("detectEmulator") { + detectEmulatorOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray()) + } + } + + fun detectVpnProperties(): Array { + return traceArray("detectVpnProperties") { + detectVpnPropertiesOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnProperties() }.getOrDefault(emptyArray()) + } + } + + fun detectVpnLeaks(): Array { + return traceArray("detectVpnLeaks") { + detectVpnLeaksOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnLeaks() }.getOrDefault(emptyArray()) + } + } + + fun detectVpnAdvanced(): Array { + return traceArray("detectVpnAdvanced") { + detectVpnAdvancedOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnAdvanced() }.getOrDefault(emptyArray()) + } + } + + fun detectVpnSyscalls(): Array { + return traceArray("detectVpnSyscalls") { + detectVpnSyscallsOverride?.invoke() + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnSyscalls() }.getOrDefault(emptyArray()) + } + } + + fun detectVpnDetector(cancellationSignal: ScanCancellationSignal? = null): Array { + return traceArray("detectVpnDetector") { + detectVpnDetectorOverride?.invoke(cancellationSignal) + ?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnDetector(cancellationSignal) }.getOrDefault(emptyArray()) + } + } + + private fun traceArray( + source: String, + target: String? = null, + block: () -> Array, + ): Array = traceValue(source, target, block) { it.joinToString("\n") } + + private fun traceValue( + source: String, + target: String? = null, + block: () -> T, + render: (T) -> String, + ): T { + val executionContext = ScanExecutionContext.currentOrDefault() + val startedAt = System.nanoTime() + return try { + block().also { result -> + executionContext.diagnosticCollector?.record( + category = "nat", + source = source, + target = target, + status = "completed", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = render(result), + ) + } + } catch (error: Throwable) { + executionContext.diagnosticCollector?.record( + category = "nat", + source = source, + target = target, + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = error.message.orEmpty(), + ) + throw error + } } internal fun resetForTests() { @@ -154,6 +262,11 @@ object NativeSignsBridge { netlinkSockDiagOverride = null detectRootOverride = null detectEmulatorOverride = null + detectVpnPropertiesOverride = null + detectVpnLeaksOverride = null + detectVpnAdvancedOverride = null + detectVpnSyscallsOverride = null + detectVpnDetectorOverride = null initialized = false libraryLoaded = false lastLoadError = null @@ -170,4 +283,9 @@ object NativeSignsBridge { private external fun nativeNetlinkSockDiag(family: Int, protocol: Int): Array private external fun nativeDetectRoot(): Array private external fun nativeDetectEmulator(): Array + private external fun nativeDetectVpnProperties(): Array + private external fun nativeDetectVpnLeaks(): Array + private external fun nativeDetectVpnAdvanced(): Array + private external fun nativeDetectVpnSyscalls(): Array + private external fun nativeDetectVpnDetector(cancellationSignal: ScanCancellationSignal?): Array } diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/StunBindingClient.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/StunBindingClient.kt index 7ab61c1..cfd7ab4 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/StunBindingClient.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/StunBindingClient.kt @@ -44,6 +44,7 @@ object StunBindingClient { timeoutMs: Int = 3_000, executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(), ): Result { + val startedAt = System.nanoTime() return runCatching { executionContext.throwIfCancelled() val resolvedAddresses = ResolverNetworkStack.lookup( @@ -75,6 +76,8 @@ object StunBindingClient { } } throw lastError ?: IOException("No STUN response from $host:$port") + }.also { result -> + recordDiagnostic(executionContext, host, port, startedAt, listOf(result)) } } @@ -86,6 +89,7 @@ object StunBindingClient { timeoutMs: Int = 3_000, executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(), ): DualStackBindingResult { + val startedAt = System.nanoTime() executionContext.throwIfCancelled() val allAddresses = runCatching { ResolverNetworkStack.lookup( @@ -94,7 +98,17 @@ object StunBindingClient { binding = binding, cancellationSignal = executionContext.cancellationSignal, ).distinctBy { it.hostAddress } - }.getOrElse { return DualStackBindingResult(null, null) } + }.getOrElse { error -> + executionContext.diagnosticCollector?.record( + category = "stn", + source = "STUN", + target = "$host:$port", + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = error.message.orEmpty(), + ) + return DualStackBindingResult(null, null) + } val ipv4Addresses = allAddresses.filterIsInstance() val ipv6Addresses = allAddresses.filterIsInstance() @@ -126,7 +140,15 @@ object StunBindingClient { return DualStackBindingResult( ipv4Result = probeFamily(ipv4Addresses), ipv6Result = probeFamily(ipv6Addresses), - ) + ).also { result -> + recordDiagnostic( + executionContext, + host, + port, + startedAt, + listOfNotNull(result.ipv4Result, result.ipv6Result), + ) + } } internal fun probeWithDatagramExchange( @@ -136,6 +158,7 @@ object StunBindingClient { exchange: (ByteArray) -> Socks5UdpAssociateClient.UdpDatagram, executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(), ): Result { + val startedAt = System.nanoTime() return runCatching { executionContext.throwIfCancelled() val transactionId = Random.nextBytes(12) @@ -156,9 +179,40 @@ object StunBindingClient { if (error is Exception) { rethrowIfCancellation(error, executionContext) } + }.also { result -> + recordDiagnostic(executionContext, host, port, startedAt, listOf(result)) } } + private fun recordDiagnostic( + executionContext: ScanExecutionContext, + host: String, + port: Int, + startedAt: Long, + results: List>, + ) { + executionContext.diagnosticCollector?.record( + category = "stn", + source = "STUN", + target = "$host:$port", + status = when { + results.isEmpty() -> "unavailable" + results.any { it.isSuccess } -> "completed" + else -> "error" + }, + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = results.joinToString("\n") { result -> + result.fold( + onSuccess = { value -> + "resolved=${value.resolvedIps},remote=${value.remoteIp}:${value.remotePort}," + + "mapped=${value.mappedIp}:${value.mappedPort}" + }, + onFailure = { error -> "error=${error.message}" }, + ) + }, + ) + } + private fun sendBindingRequest( host: String, port: Int, diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/SystemPingProber.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/SystemPingProber.kt index 36f37cb..2ef868c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/SystemPingProber.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/SystemPingProber.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException import kotlin.math.roundToInt +import kotlin.concurrent.thread object SystemPingProber { private const val DEFAULT_COUNT = 3 @@ -20,6 +21,7 @@ object SystemPingProber { internal data class CommandResult( val exitCode: Int, val output: String, + val stderr: String = "", ) data class PingResult( @@ -66,7 +68,23 @@ object SystemPingProber { for (command in commands) { executionContext.throwIfCancelled() try { + val startedAt = System.nanoTime() val commandResult = runCommand(command, executionContext) + executionContext.diagnosticCollector?.record( + category = "icmp", + source = "ping", + target = address, + status = "exit ${commandResult.exitCode}", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = buildString { + appendLine("command=${command.joinToString(" ")}") + appendLine("exitCode=${commandResult.exitCode}") + appendLine("stdout:") + appendLine(commandResult.output) + appendLine("stderr:") + append(commandResult.stderr) + }, + ) return@withContext try { parse(address, commandResult) } catch (error: IOException) { @@ -131,20 +149,27 @@ object SystemPingProber { ): CommandResult { runCommandOverride?.let { return it(command) } - val process = ProcessBuilder(command) - .redirectErrorStream(true) - .start() + val process = ProcessBuilder(command).start() val registration = executionContext.cancellationSignal.register { runCatching { process.destroyForcibly() } } try { - val output = process.inputStream.bufferedReader().use { it.readText() } - executionContext.throwIfCancelled() + var output = "" + var stderr = "" + val stdoutReader = thread(name = "rkn-ping-stdout") { + output = runCatching { process.inputStream.bufferedReader().use { it.readText() } }.getOrDefault("") + } + val stderrReader = thread(name = "rkn-ping-stderr") { + stderr = runCatching { process.errorStream.bufferedReader().use { it.readText() } }.getOrDefault("") + } val exitCode = process.waitFor() + stdoutReader.join() + stderrReader.join() executionContext.throwIfCancelled() return CommandResult( exitCode = exitCode, output = output, + stderr = stderr, ) } finally { registration.dispose() diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt index fe4e8fe..8c868e3 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/TunProbeDiagnosticsFormatter.kt @@ -13,6 +13,8 @@ import java.util.Locale object TunProbeDiagnosticsFormatter { + private const val NONE = "" + fun format( diagnostics: TunProbeDiagnostics, settings: CheckSettings, @@ -109,8 +111,8 @@ object TunProbeDiagnosticsFormatter { builder.appendLine("available: true") builder.appendLine("interfaceName: ${path.interfaceName ?: ""}") builder.appendLine("selectedMode: ${path.selectedMode?.name ?: "NONE"}") - builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: ""}") - builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: ""}") + builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: NONE}") + builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: NONE}") builder.appendLine("dnsPathMismatch: ${path.dnsPathMismatch}") appendAttempt(builder, "strict", path.strict) appendAttempt(builder, "curl-compatible", path.curlCompatible) @@ -122,8 +124,8 @@ object TunProbeDiagnosticsFormatter { attempt: TunProbeAttemptDiagnostics, ) { builder.appendLine("$label.status: ${attempt.status}") - builder.appendLine("$label.ip: ${attempt.ip?.let(::maskIp) ?: ""}") - builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: ""}") + 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()) { @@ -138,9 +140,9 @@ object TunProbeDiagnosticsFormatter { private fun formatEndpointAttempt(attempt: TunEndpointAttempt): String { val result = when (attempt.status) { - PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: ""}" + PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: NONE}" PublicIpProbeStatus.FAILED, - PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: ""}" + PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: NONE}" } return "${attempt.endpoint} [${attempt.familyHint}] -> ${attempt.status} ($result)" } @@ -191,14 +193,14 @@ object TunProbeDiagnosticsFormatter { DnsResolverMode.DIRECT -> { val servers = settings.resolverConfig.effectiveDirectServers() .joinToString(", ") { maskIp(it) } - .ifBlank { "" } + .ifBlank { NONE } "DIRECT preset=${settings.resolverConfig.preset.name} servers=$servers" } DnsResolverMode.DOH -> { val bootstrap = settings.resolverConfig.effectiveDohBootstrapHosts() .joinToString(", ") { maskIp(it) } - .ifBlank { "" } - "DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: ""} bootstrap=$bootstrap" + .ifBlank { NONE } + "DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: NONE} bootstrap=$bootstrap" } } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/probe/XrayApiClient.kt b/app/src/main/java/com/notcvnt/rknhardering/probe/XrayApiClient.kt index abc3b10..625f702 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/probe/XrayApiClient.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/probe/XrayApiClient.kt @@ -26,6 +26,7 @@ class XrayApiClient( deadlineMs: Long = 600, executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(), ): Result = withContext(Dispatchers.IO) { + val startedAt = System.nanoTime() val channel = OkHttpChannelBuilder.forAddress(host, port) .usePlaintext() .build() @@ -69,15 +70,40 @@ class XrayApiClient( ) } - Result.success( - XrayApiScanResult( - endpoint = XrayApiEndpoint(host = host, port = port), - outbounds = outbounds, - handlerAvailable = true, - ), + val scanResult = XrayApiScanResult( + endpoint = XrayApiEndpoint(host = host, port = port), + outbounds = outbounds, + handlerAvailable = true, ) + executionContext.diagnosticCollector?.record( + category = "byp", + source = "Xray gRPC API", + target = "$host:$port", + status = "available", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = buildString { + appendLine("outboundCount=${outbounds.size}") + outbounds.forEachIndexed { index, outbound -> + appendLine( + "outbound[$index]=tag:${outbound.tag},protocol:${outbound.protocolName}," + + "address:${outbound.address},port:${outbound.port},sni:${outbound.sni}," + + "uuidPresent:${!outbound.uuid.isNullOrBlank()}," + + "publicKeyPresent:${!outbound.publicKey.isNullOrBlank()}", + ) + } + }, + ) + Result.success(scanResult) } catch (e: Exception) { rethrowIfCancellation(e, executionContext) + executionContext.diagnosticCollector?.record( + category = "byp", + source = "Xray gRPC API", + target = "$host:$port", + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = e.message.orEmpty(), + ) Result.failure(e) } finally { registration.dispose() @@ -91,6 +117,7 @@ class XrayApiClient( deadlineMs: Long = 600, executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(), ): Result = withContext(Dispatchers.IO) { + val startedAt = System.nanoTime() val channel = OkHttpChannelBuilder.forAddress(host, port) .usePlaintext() .build() @@ -108,18 +135,33 @@ class XrayApiClient( stub.queryStats(QueryStatsRequest.getDefaultInstance()) } - Result.success( - XrayStatsSummary( - statCount = response.statCount, - sampleNames = response.statList - .asSequence() - .mapNotNull { stat -> stat.name.takeIf { it.isNotBlank() } } - .take(8) - .toList(), - ), + val summary = XrayStatsSummary( + statCount = response.statCount, + sampleNames = response.statList + .asSequence() + .mapNotNull { stat -> stat.name.takeIf { it.isNotBlank() } } + .take(8) + .toList(), ) + executionContext.diagnosticCollector?.record( + category = "byp", + source = "Xray stats API", + target = "$host:$port", + status = "available", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = "statCount=${summary.statCount}", + ) + Result.success(summary) } catch (e: Exception) { rethrowIfCancellation(e, executionContext) + executionContext.diagnosticCollector?.record( + category = "byp", + source = "Xray stats API", + target = "$host:$port", + status = "error", + durationMs = (System.nanoTime() - startedAt) / 1_000_000, + body = e.message.orEmpty(), + ) Result.failure(e) } finally { registration.dispose() diff --git a/app/src/main/java/com/notcvnt/rknhardering/ui/main/SimpleResultModels.kt b/app/src/main/java/com/notcvnt/rknhardering/ui/main/SimpleResultModels.kt new file mode 100644 index 0000000..bae8e93 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/ui/main/SimpleResultModels.kt @@ -0,0 +1,385 @@ +package com.notcvnt.rknhardering.ui.main + +import com.notcvnt.rknhardering.model.BypassResult +import com.notcvnt.rknhardering.model.CallTransportLeakResult +import com.notcvnt.rknhardering.model.CallTransportStatus +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.CdnPullingResult +import com.notcvnt.rknhardering.model.DomainReachabilityResult +import com.notcvnt.rknhardering.model.EvidenceSource +import com.notcvnt.rknhardering.model.IpComparisonResult +import com.notcvnt.rknhardering.model.LocalProxyCheckStatus +import com.notcvnt.rknhardering.model.StunProbeGroupResult + +internal enum class SimpleResultStatus { + RUNNING, + CLEAN, + REVIEW, + DETECTED, + ERROR, + DISABLED, +} + +internal enum class SimpleSignalArea { + PUBLIC_ADDRESS, + DEVICE_NETWORK, + LOCAL_APP, + LOCAL_PROXY, + NETWORK_ROUTE, + CALL_ROUTE, + LOCATION, + REMOTE_SITE, + DEVICE_ENVIRONMENT, + MULTIPLE, + NONE, +} + +internal enum class SimpleResultCause { + IP_RU_SERVICES_DISAGREE, + IP_NON_RU_SERVICES_DISAGREE, + IP_GROUPS_DISAGREE, + IP_FAMILIES_DIFFER, + IP_PARTIAL_RESPONSE, + IP_UNAVAILABLE, + CDN_RESPONSES_DIFFER, + CDN_PARTIAL_RESPONSE, + PUBLIC_IP_LOCATION, + VPN_NETWORK_STATE, + ACTIVE_VPN_APP, + SYSTEM_PROXY, + LOCAL_PROXY, + VPN_INTERFACE, + VPN_ROUTE, + DNS_REDIRECTION, + TRAFFIC_BYPASS, + PROXY_AUTH_REQUIRED, + LOCATION_CONFLICT, + ICMP_RESPONSE_MISMATCH, + RTT_ROUTE_PATTERN, + TELEGRAM_CALL_PATH, + WHATSAPP_CALL_PATH, + CALL_CHECK_UNAVAILABLE, + DOMAIN_DNS_MISMATCH, + DOMAIN_TCP_MISMATCH, + DOMAIN_TLS_MISMATCH, + PUBLIC_DATA_UNAVAILABLE, + NETWORK_DATA_UNAVAILABLE, + REMOTE_DATA_UNAVAILABLE, + DEVICE_DATA_UNAVAILABLE, + DEVICE_ENVIRONMENT, +} + +internal data class SimpleCardModel( + val status: SimpleResultStatus, + val area: SimpleSignalArea = SimpleSignalArea.NONE, + val extraInformation: Boolean = false, + val causes: List = emptyList(), +) + +internal object SimpleResultModels { + fun category(result: CategoryResult, extraInformation: Boolean = false): SimpleCardModel { + val status = status(result.detected, result.needsReview, result.hasError) + val hasDiagnosticAppSignal = result.matchedApps.isNotEmpty() || + result.evidence.any { + it.source == EvidenceSource.INSTALLED_APP || + it.source == EvidenceSource.VPN_SERVICE_DECLARATION + } + val signalSources = buildSet { + result.evidence.filter { it.detected }.mapTo(this) { it.source } + result.findings + .filter { it.detected || it.needsReview } + .mapNotNullTo(this) { it.source } + if (result.activeApps.isNotEmpty()) add(EvidenceSource.ACTIVE_VPN) + } + val areaSources = buildSet { + addAll(signalSources) + result.findings.filter { it.isError }.mapNotNullTo(this) { it.source } + if (result.matchedApps.isNotEmpty()) add(EvidenceSource.INSTALLED_APP) + } + return SimpleCardModel( + status = status, + area = signalArea(areaSources), + extraInformation = extraInformation || hasDiagnosticAppSignal, + causes = if (status == SimpleResultStatus.CLEAN) { + emptyList() + } else { + buildList { + result.findings.filter { it.isError }.mapNotNullTo(this) { errorCause(it.source) } + signalSources.mapNotNullTo(this) { signalCause(it) } + }.distinct().sortedBy { it.ordinal }.take(MAX_CAUSES) + }, + ) + } + + fun ipComparison(result: IpComparisonResult): SimpleCardModel { + val successfulCount = result.ruGroup.responses.count { it.ip != null } + + result.nonRuGroup.responses.count { it.ip != null } + val ruIp = result.ruGroup.canonicalIp + val nonRuIp = result.nonRuGroup.canonicalIp + val causes = buildList { + if (result.ruGroup.detected) add(SimpleResultCause.IP_RU_SERVICES_DISAGREE) + if (result.nonRuGroup.detected) add(SimpleResultCause.IP_NON_RU_SERVICES_DISAGREE) + if (ruIp != null && nonRuIp != null) { + if ((ruIp.contains(':')) != (nonRuIp.contains(':'))) { + add(SimpleResultCause.IP_FAMILIES_DIFFER) + } else if (ruIp != nonRuIp) { + add(SimpleResultCause.IP_GROUPS_DISAGREE) + } + } + if (successfulCount == 0 && (result.hasError || result.needsReview) && isEmpty()) { + add(SimpleResultCause.IP_UNAVAILABLE) + } else if (result.needsReview && isEmpty()) { + add(SimpleResultCause.IP_PARTIAL_RESPONSE) + } + } + return SimpleCardModel( + status = status(result.detected, result.needsReview, result.hasError), + area = SimpleSignalArea.PUBLIC_ADDRESS, + causes = causes.distinct().take(MAX_CAUSES), + ) + } + + fun cdn(result: CdnPullingResult): SimpleCardModel { + val cause = when { + result.hasError && result.responses.none { it.ip != null || it.ipv4 != null || it.ipv6 != null } -> + SimpleResultCause.REMOTE_DATA_UNAVAILABLE + result.detected -> SimpleResultCause.CDN_RESPONSES_DIFFER + result.needsReview -> SimpleResultCause.CDN_PARTIAL_RESPONSE + else -> null + } + return SimpleCardModel( + status = status(result.detected, result.needsReview, result.hasError), + area = SimpleSignalArea.REMOTE_SITE, + extraInformation = true, + causes = listOfNotNull(cause), + ) + } + + fun bypass(result: BypassResult): SimpleCardModel { + val sources = buildSet { + result.evidence.filter { it.detected }.mapTo(this) { it.source } + result.findings.filter { it.detected || it.needsReview }.mapNotNullTo(this) { it.source } + } + val causes = buildList { + result.findings.filter { it.isError }.mapNotNullTo(this) { errorCause(it.source) } + sources.mapNotNullTo(this) { signalCause(it) } + result.proxyChecks.forEach { check -> + when (check.status) { + LocalProxyCheckStatus.CONFIRMED_BYPASS -> add(SimpleResultCause.TRAFFIC_BYPASS) + LocalProxyCheckStatus.AUTH_REQUIRED -> add(SimpleResultCause.PROXY_AUTH_REQUIRED) + LocalProxyCheckStatus.PROXY_IP_UNAVAILABLE, + LocalProxyCheckStatus.DIRECT_IP_UNAVAILABLE, + -> add(SimpleResultCause.PUBLIC_DATA_UNAVAILABLE) + LocalProxyCheckStatus.SAME_IP -> Unit + } + } + }.distinct().sortedBy { it.ordinal }.take(MAX_CAUSES) + return SimpleCardModel( + status = status(result.detected, result.needsReview, result.hasError), + area = signalArea(sources), + causes = causes, + ) + } + + fun callTransport( + leaks: List, + stunGroups: List, + ): SimpleCardModel { + val status = when { + leaks.any { it.status == CallTransportStatus.ERROR } -> SimpleResultStatus.ERROR + leaks.any { it.status == CallTransportStatus.NEEDS_REVIEW } -> SimpleResultStatus.REVIEW + leaks.isEmpty() && stunGroups.isEmpty() -> SimpleResultStatus.ERROR + else -> SimpleResultStatus.CLEAN + } + val causes = when { + status == SimpleResultStatus.ERROR -> listOf(SimpleResultCause.CALL_CHECK_UNAVAILABLE) + status == SimpleResultStatus.REVIEW -> leaks + .filter { it.status == CallTransportStatus.NEEDS_REVIEW } + .map { + when (it.service) { + com.notcvnt.rknhardering.model.CallTransportService.TELEGRAM -> + SimpleResultCause.TELEGRAM_CALL_PATH + com.notcvnt.rknhardering.model.CallTransportService.WHATSAPP -> + SimpleResultCause.WHATSAPP_CALL_PATH + } + } + .distinct() + .take(MAX_CAUSES) + else -> emptyList() + } + return SimpleCardModel(status, SimpleSignalArea.CALL_ROUTE, causes = causes) + } + + fun domainReachability(result: DomainReachabilityResult): SimpleCardModel { + val status = when { + result.isEmpty -> SimpleResultStatus.ERROR + result.responses.any { !it.matchesExpectation } -> SimpleResultStatus.DETECTED + else -> SimpleResultStatus.CLEAN + } + val causes = if (result.isEmpty) { + listOf(SimpleResultCause.REMOTE_DATA_UNAVAILABLE) + } else { + buildList { + result.responses.filter { !it.matchesExpectation }.forEach { response -> + if ((response.dnsStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK) != + response.expectedDnsAvailable + ) { + add(SimpleResultCause.DOMAIN_DNS_MISMATCH) + } + if (response.dnsStatus != com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.FAILED && + (response.tcpStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK) != + response.expectedTcpAvailable + ) { + add(SimpleResultCause.DOMAIN_TCP_MISMATCH) + } + if (response.tcpStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK && + (response.tlsStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK) != + response.expectedTlsAvailable + ) { + add(SimpleResultCause.DOMAIN_TLS_MISMATCH) + } + } + }.distinct().take(MAX_CAUSES) + } + return SimpleCardModel( + status = status, + area = SimpleSignalArea.REMOTE_SITE, + extraInformation = true, + causes = causes, + ) + } + + private fun status(detected: Boolean, needsReview: Boolean, hasError: Boolean): SimpleResultStatus = + when { + hasError -> SimpleResultStatus.ERROR + detected -> SimpleResultStatus.DETECTED + needsReview -> SimpleResultStatus.REVIEW + else -> SimpleResultStatus.CLEAN + } + + private fun signalArea(sources: Set): SimpleSignalArea { + val areas = sources.mapTo(linkedSetOf()) { source -> + when (source) { + EvidenceSource.GEO_IP -> SimpleSignalArea.PUBLIC_ADDRESS + EvidenceSource.INSTALLED_APP, + EvidenceSource.VPN_SERVICE_DECLARATION, + EvidenceSource.ACTIVE_VPN, + -> SimpleSignalArea.LOCAL_APP + EvidenceSource.LOCAL_PROXY, + EvidenceSource.XRAY_API, + EvidenceSource.CLASH_API, + EvidenceSource.PROXY_AUTH_BYPASS, + EvidenceSource.PROXY_TECHNICAL_SIGNAL, + -> SimpleSignalArea.LOCAL_PROXY + EvidenceSource.ROUTING, + EvidenceSource.DNS, + EvidenceSource.NETWORK_INTERFACE, + EvidenceSource.SPLIT_TUNNEL_BYPASS, + EvidenceSource.VPN_GATEWAY_LEAK, + EvidenceSource.VPN_NETWORK_BINDING, + EvidenceSource.TUN_ACTIVE_PROBE, + EvidenceSource.DIRECT_NETWORK_CAPABILITIES, + EvidenceSource.INDIRECT_NETWORK_CAPABILITIES, + EvidenceSource.SYSTEM_PROXY, + -> SimpleSignalArea.NETWORK_ROUTE + EvidenceSource.TELEGRAM_CALL_TRANSPORT, + EvidenceSource.WHATSAPP_CALL_TRANSPORT, + EvidenceSource.STUN_PROBE, + -> SimpleSignalArea.CALL_ROUTE + EvidenceSource.LOCATION_SIGNALS, + EvidenceSource.HOME_ROUTED_ROAMING, + -> SimpleSignalArea.LOCATION + EvidenceSource.NATIVE_INTERFACE, + EvidenceSource.NATIVE_ROUTE, + EvidenceSource.NATIVE_SOCKET, + EvidenceSource.NATIVE_HOOK_MARKERS, + EvidenceSource.NATIVE_JVM_MISMATCH, + EvidenceSource.NATIVE_LIBRARY_INTEGRITY, + EvidenceSource.NATIVE_ROOT_DETECTION, + EvidenceSource.NATIVE_EMULATOR, + EvidenceSource.SANDBOX_ISOLATION, + EvidenceSource.DUMPSYS, + -> SimpleSignalArea.DEVICE_ENVIRONMENT + EvidenceSource.ICMP_SPOOFING, + EvidenceSource.RTT_TRIANGULATION, + -> SimpleSignalArea.REMOTE_SITE + } + } + return when (areas.size) { + 0 -> SimpleSignalArea.NONE + 1 -> areas.first() + else -> SimpleSignalArea.MULTIPLE + } + } + + private fun signalCause(source: EvidenceSource): SimpleResultCause? = when (source) { + EvidenceSource.GEO_IP -> SimpleResultCause.PUBLIC_IP_LOCATION + EvidenceSource.DIRECT_NETWORK_CAPABILITIES, + EvidenceSource.INDIRECT_NETWORK_CAPABILITIES, + EvidenceSource.DUMPSYS, + -> SimpleResultCause.VPN_NETWORK_STATE + EvidenceSource.ACTIVE_VPN -> SimpleResultCause.ACTIVE_VPN_APP + EvidenceSource.SYSTEM_PROXY -> SimpleResultCause.SYSTEM_PROXY + EvidenceSource.LOCAL_PROXY, + EvidenceSource.XRAY_API, + EvidenceSource.CLASH_API, + EvidenceSource.PROXY_TECHNICAL_SIGNAL, + -> SimpleResultCause.LOCAL_PROXY + EvidenceSource.PROXY_AUTH_BYPASS -> SimpleResultCause.PROXY_AUTH_REQUIRED + EvidenceSource.NETWORK_INTERFACE, + EvidenceSource.NATIVE_INTERFACE, + -> SimpleResultCause.VPN_INTERFACE + EvidenceSource.ROUTING, + EvidenceSource.NATIVE_ROUTE, + -> SimpleResultCause.VPN_ROUTE + EvidenceSource.DNS -> SimpleResultCause.DNS_REDIRECTION + EvidenceSource.SPLIT_TUNNEL_BYPASS, + EvidenceSource.VPN_GATEWAY_LEAK, + EvidenceSource.VPN_NETWORK_BINDING, + EvidenceSource.TUN_ACTIVE_PROBE, + -> SimpleResultCause.TRAFFIC_BYPASS + EvidenceSource.LOCATION_SIGNALS, + EvidenceSource.HOME_ROUTED_ROAMING, + -> SimpleResultCause.LOCATION_CONFLICT + EvidenceSource.ICMP_SPOOFING -> SimpleResultCause.ICMP_RESPONSE_MISMATCH + EvidenceSource.RTT_TRIANGULATION -> SimpleResultCause.RTT_ROUTE_PATTERN + EvidenceSource.TELEGRAM_CALL_TRANSPORT -> SimpleResultCause.TELEGRAM_CALL_PATH + EvidenceSource.WHATSAPP_CALL_TRANSPORT -> SimpleResultCause.WHATSAPP_CALL_PATH + EvidenceSource.STUN_PROBE -> SimpleResultCause.CALL_CHECK_UNAVAILABLE + EvidenceSource.NATIVE_SOCKET, + EvidenceSource.NATIVE_HOOK_MARKERS, + EvidenceSource.NATIVE_JVM_MISMATCH, + EvidenceSource.NATIVE_LIBRARY_INTEGRITY, + EvidenceSource.NATIVE_ROOT_DETECTION, + EvidenceSource.NATIVE_EMULATOR, + EvidenceSource.SANDBOX_ISOLATION, + -> SimpleResultCause.DEVICE_ENVIRONMENT + EvidenceSource.INSTALLED_APP, + EvidenceSource.VPN_SERVICE_DECLARATION, + -> null + } + + private fun errorCause(source: EvidenceSource?): SimpleResultCause = when (source) { + EvidenceSource.GEO_IP -> SimpleResultCause.PUBLIC_DATA_UNAVAILABLE + EvidenceSource.ICMP_SPOOFING, + EvidenceSource.RTT_TRIANGULATION, + EvidenceSource.TELEGRAM_CALL_TRANSPORT, + EvidenceSource.WHATSAPP_CALL_TRANSPORT, + EvidenceSource.STUN_PROBE, + -> SimpleResultCause.REMOTE_DATA_UNAVAILABLE + EvidenceSource.NATIVE_INTERFACE, + EvidenceSource.NATIVE_ROUTE, + EvidenceSource.NATIVE_SOCKET, + EvidenceSource.NATIVE_HOOK_MARKERS, + EvidenceSource.NATIVE_JVM_MISMATCH, + EvidenceSource.NATIVE_LIBRARY_INTEGRITY, + EvidenceSource.NATIVE_ROOT_DETECTION, + EvidenceSource.NATIVE_EMULATOR, + EvidenceSource.SANDBOX_ISOLATION, + -> SimpleResultCause.DEVICE_DATA_UNAVAILABLE + null -> SimpleResultCause.NETWORK_DATA_UNAVAILABLE + else -> SimpleResultCause.NETWORK_DATA_UNAVAILABLE + } + + private const val MAX_CAUSES = 3 +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/ui/main/render/SimpleResultRenderer.kt b/app/src/main/java/com/notcvnt/rknhardering/ui/main/render/SimpleResultRenderer.kt new file mode 100644 index 0000000..32acb08 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/ui/main/render/SimpleResultRenderer.kt @@ -0,0 +1,168 @@ +package com.notcvnt.rknhardering.ui.main.render + +import android.graphics.Typeface +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.ui.main.SimpleCardModel +import com.notcvnt.rknhardering.ui.main.SimpleResultCause +import com.notcvnt.rknhardering.ui.main.SimpleResultStatus +import com.notcvnt.rknhardering.ui.main.SimpleSignalArea +import java.util.IdentityHashMap + +internal class SimpleResultRenderer( + private val env: MainRenderEnvironment, +) { + private data class RenderedCard( + val root: View, + val originalVisibility: IdentityHashMap, + ) + + private val renderedCards = mutableMapOf() + + fun render(body: ViewGroup, whatWasChecked: String, model: SimpleCardModel) { + clear(body) + val originalVisibility = IdentityHashMap() + repeat(body.childCount) { index -> + body.getChildAt(index).let { child -> + originalVisibility[child] = child.visibility + child.visibility = View.GONE + } + } + + val root = LinearLayout(env.context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 10.dp, 0, 0) + addView(label(R.string.simple_result_checked_label)) + addView(value(whatWasChecked)) + addView(label(R.string.simple_result_outcome_label, topMargin = 10.dp)) + addView(value(statusText(model.status), Typeface.BOLD)) + addView(label(R.string.simple_result_explanation_label, topMargin = 10.dp)) + addView(value(explanationText(model))) + if (model.extraInformation) { + addView(value(env.context.getString(R.string.simple_result_extra_information)).apply { + setPadding(0, 10.dp, 0, 0) + }) + } + } + body.addView(root) + renderedCards[body] = RenderedCard(root, originalVisibility) + } + + fun clear() { + renderedCards.keys.toList().forEach(::clear) + } + + private fun clear(body: ViewGroup) { + val rendered = renderedCards.remove(body) ?: return + body.removeView(rendered.root) + rendered.originalVisibility.forEach { (view, visibility) -> + if (view.parent === body) view.visibility = visibility + } + } + + private fun label(textRes: Int, topMargin: Int = 0): TextView = TextView(env.context).apply { + setText(textRes) + setTextColor(env.onSurfaceVariantColor()) + textSize = 12f + typeface = Typeface.DEFAULT_BOLD + if (topMargin > 0) setPadding(0, topMargin, 0, 0) + } + + private fun value(value: String, style: Int = Typeface.NORMAL): TextView = TextView(env.context).apply { + text = value + setTextColor(env.onSurfaceColor()) + textSize = 14f + typeface = Typeface.create(Typeface.DEFAULT, style) + setLineSpacing(0f, 1.08f) + } + + private fun statusText(status: SimpleResultStatus): String = env.context.getString( + when (status) { + SimpleResultStatus.RUNNING -> R.string.simple_result_running + SimpleResultStatus.CLEAN -> R.string.simple_result_clean + SimpleResultStatus.REVIEW -> R.string.simple_result_review + SimpleResultStatus.DETECTED -> R.string.simple_result_detected + SimpleResultStatus.ERROR -> R.string.simple_result_error + SimpleResultStatus.DISABLED -> R.string.simple_result_disabled + }, + ) + + private fun explanationText(model: SimpleCardModel): String { + if (model.status == SimpleResultStatus.RUNNING) { + return env.context.getString(R.string.simple_result_explanation_running) + } + if (model.status == SimpleResultStatus.CLEAN) { + return env.context.getString(R.string.simple_result_explanation_clean) + } + if (model.causes.isNotEmpty()) { + val explanations = model.causes.map { env.context.getString(causeText(it)) } + return if (explanations.size == 1) { + explanations.single() + } else { + explanations.joinToString(separator = "\n") { "\u2022 $it" } + } + } + if (model.status == SimpleResultStatus.ERROR) { + return env.context.getString(R.string.simple_result_explanation_error) + } + if (model.status == SimpleResultStatus.DISABLED) { + return env.context.getString(R.string.simple_result_disabled) + } + return env.context.getString( + when (model.area) { + SimpleSignalArea.PUBLIC_ADDRESS -> R.string.simple_result_area_public_address + SimpleSignalArea.DEVICE_NETWORK -> R.string.simple_result_area_device_network + SimpleSignalArea.LOCAL_APP -> R.string.simple_result_area_local_app + SimpleSignalArea.LOCAL_PROXY -> R.string.simple_result_area_local_proxy + SimpleSignalArea.NETWORK_ROUTE -> R.string.simple_result_area_network_route + SimpleSignalArea.CALL_ROUTE -> R.string.simple_result_area_call_route + SimpleSignalArea.LOCATION -> R.string.simple_result_area_location + SimpleSignalArea.REMOTE_SITE -> R.string.simple_result_area_remote_site + SimpleSignalArea.DEVICE_ENVIRONMENT -> R.string.simple_result_area_device_environment + SimpleSignalArea.MULTIPLE -> R.string.simple_result_area_multiple + SimpleSignalArea.NONE -> R.string.simple_result_explanation_review + }, + ) + } + + private fun causeText(cause: SimpleResultCause): Int = when (cause) { + SimpleResultCause.IP_RU_SERVICES_DISAGREE -> R.string.simple_cause_ip_ru_services_disagree + SimpleResultCause.IP_NON_RU_SERVICES_DISAGREE -> R.string.simple_cause_ip_non_ru_services_disagree + SimpleResultCause.IP_GROUPS_DISAGREE -> R.string.simple_cause_ip_groups_disagree + SimpleResultCause.IP_FAMILIES_DIFFER -> R.string.simple_cause_ip_families_differ + SimpleResultCause.IP_PARTIAL_RESPONSE -> R.string.simple_cause_ip_partial_response + SimpleResultCause.IP_UNAVAILABLE -> R.string.simple_cause_ip_unavailable + SimpleResultCause.CDN_RESPONSES_DIFFER -> R.string.simple_cause_cdn_responses_differ + SimpleResultCause.CDN_PARTIAL_RESPONSE -> R.string.simple_cause_cdn_partial_response + SimpleResultCause.PUBLIC_IP_LOCATION -> R.string.simple_cause_public_ip_location + SimpleResultCause.VPN_NETWORK_STATE -> R.string.simple_cause_vpn_network_state + SimpleResultCause.ACTIVE_VPN_APP -> R.string.simple_cause_active_vpn_app + SimpleResultCause.SYSTEM_PROXY -> R.string.simple_cause_system_proxy + SimpleResultCause.LOCAL_PROXY -> R.string.simple_cause_local_proxy + SimpleResultCause.VPN_INTERFACE -> R.string.simple_cause_vpn_interface + SimpleResultCause.VPN_ROUTE -> R.string.simple_cause_vpn_route + SimpleResultCause.DNS_REDIRECTION -> R.string.simple_cause_dns_redirection + SimpleResultCause.TRAFFIC_BYPASS -> R.string.simple_cause_traffic_bypass + SimpleResultCause.PROXY_AUTH_REQUIRED -> R.string.simple_cause_proxy_auth_required + SimpleResultCause.LOCATION_CONFLICT -> R.string.simple_cause_location_conflict + SimpleResultCause.ICMP_RESPONSE_MISMATCH -> R.string.simple_cause_icmp_response_mismatch + SimpleResultCause.RTT_ROUTE_PATTERN -> R.string.simple_cause_rtt_route_pattern + SimpleResultCause.TELEGRAM_CALL_PATH -> R.string.simple_cause_telegram_call_path + SimpleResultCause.WHATSAPP_CALL_PATH -> R.string.simple_cause_whatsapp_call_path + SimpleResultCause.CALL_CHECK_UNAVAILABLE -> R.string.simple_cause_call_check_unavailable + SimpleResultCause.DOMAIN_DNS_MISMATCH -> R.string.simple_cause_domain_dns_mismatch + SimpleResultCause.DOMAIN_TCP_MISMATCH -> R.string.simple_cause_domain_tcp_mismatch + SimpleResultCause.DOMAIN_TLS_MISMATCH -> R.string.simple_cause_domain_tls_mismatch + SimpleResultCause.PUBLIC_DATA_UNAVAILABLE -> R.string.simple_cause_public_data_unavailable + SimpleResultCause.NETWORK_DATA_UNAVAILABLE -> R.string.simple_cause_network_data_unavailable + SimpleResultCause.REMOTE_DATA_UNAVAILABLE -> R.string.simple_cause_remote_data_unavailable + SimpleResultCause.DEVICE_DATA_UNAVAILABLE -> R.string.simple_cause_device_data_unavailable + SimpleResultCause.DEVICE_ENVIRONMENT -> R.string.simple_cause_device_environment + } + + private val Int.dp: Int + get() = (this * env.context.resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/ui/main/render/TechnicalDataRenderer.kt b/app/src/main/java/com/notcvnt/rknhardering/ui/main/render/TechnicalDataRenderer.kt new file mode 100644 index 0000000..c754066 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/ui/main/render/TechnicalDataRenderer.kt @@ -0,0 +1,157 @@ +package com.notcvnt.rknhardering.ui.main.render + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Typeface +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import com.google.android.material.button.MaterialButton +import com.notcvnt.rknhardering.R +import com.notcvnt.rknhardering.diagnostics.DiagnosticEntry +import java.util.Locale + +internal class TechnicalDataRenderer( + private val env: MainRenderEnvironment, +) { + private val roots = mutableListOf() + + fun render(body: ViewGroup, entries: List, runTruncated: Boolean = false) { + val content = LinearLayout(env.context).apply { + orientation = LinearLayout.VERTICAL + visibility = View.GONE + } + val toggle = MaterialButton( + env.context, + null, + com.google.android.material.R.attr.materialButtonOutlinedStyle, + ).apply { + setText(R.string.technical_data_title) + gravity = Gravity.START or Gravity.CENTER_VERTICAL + contentDescription = env.context.getString(R.string.technical_data_expand_content_description) + setOnClickListener { + val expanding = content.visibility != View.VISIBLE + if (expanding && content.childCount == 0) { + buildContent(content, entries, runTruncated) + } + content.visibility = if (expanding) View.VISIBLE else View.GONE + this.contentDescription = env.context.getString( + if (expanding) { + R.string.technical_data_collapse_content_description + } else { + R.string.technical_data_expand_content_description + }, + ) + } + } + val root = LinearLayout(env.context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 12.dp, 0, 0) + addView(toggle, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + body.addView(root) + roots += root + } + + fun clear() { + roots.forEach { root -> (root.parent as? ViewGroup)?.removeView(root) } + roots.clear() + } + + private fun buildContent( + container: LinearLayout, + entries: List, + runTruncated: Boolean, + ) { + if (runTruncated) { + container.addView(text(env.context.getString(R.string.technical_data_run_truncated), bold = true)) + } + if (entries.isEmpty()) { + container.addView(text(env.context.getString(R.string.technical_data_empty))) + return + } + entries.forEach { entry -> container.addView(entryView(entry)) } + } + + private fun entryView(entry: DiagnosticEntry): View { + val metadata = buildMetadata(entry) + val copiedBlock = buildString { + appendLine(metadata) + append(entry.body) + } + return LinearLayout(env.context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 10.dp, 0, 8.dp) + addView(text(entry.source, bold = true)) + addView(text(metadata, secondary = true)) + addView(TextView(env.context).apply { + text = entry.body.ifBlank { env.context.getString(R.string.technical_data_empty_body) } + typeface = Typeface.MONOSPACE + textSize = 12f + setTextColor(env.onSurfaceColor()) + setTextIsSelectable(true) + layoutDirection = View.LAYOUT_DIRECTION_LTR + textDirection = View.TEXT_DIRECTION_LTR + gravity = Gravity.START + setHorizontallyScrolling(false) + setPadding(0, 8.dp, 0, 4.dp) + }) + addView(MaterialButton(env.context).apply { + setText(R.string.technical_data_copy_block) + contentDescription = env.context.getString( + R.string.technical_data_copy_block_content_description, + entry.source, + ) + setOnClickListener { copyBlock(copiedBlock) } + }) + } + } + + private fun buildMetadata(entry: DiagnosticEntry): String = buildString { + append(env.context.getString(R.string.technical_data_source, entry.source)) + entry.target?.takeIf { it.isNotBlank() }?.let { + appendLine() + append(env.context.getString(R.string.technical_data_target, it)) + } + appendLine() + append(env.context.getString(R.string.technical_data_status, entry.status)) + entry.durationMs?.let { + appendLine() + append(env.context.getString(R.string.technical_data_duration, it)) + } + appendLine() + append(env.context.getString(R.string.technical_data_size, formatBytes(entry.storedBytes))) + if (entry.truncated) { + appendLine() + append(env.context.getString(R.string.technical_data_truncated, formatBytes(entry.originalBytes))) + } + } + + private fun copyBlock(value: String) { + val clipboard = env.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(env.context.getString(R.string.technical_data_title), value)) + Toast.makeText(env.context, R.string.technical_data_copied, Toast.LENGTH_SHORT).show() + } + + private fun formatBytes(bytes: Int): String = when { + bytes < 1024 -> env.context.getString(R.string.technical_data_bytes, bytes) + else -> String.format(Locale.ROOT, "%.1f KiB", bytes / 1024.0) + } + + private fun text(value: String, bold: Boolean = false, secondary: Boolean = false): TextView = + TextView(env.context).apply { + text = value + textSize = if (bold) 14f else 12f + typeface = if (bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + setTextColor(if (secondary) env.onSurfaceVariantColor() else env.onSurfaceColor()) + setLineSpacing(0f, 1.06f) + } + + private val Int.dp: Int + get() = (this * env.context.resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt b/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt index c4df323..0fdff50 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/util/MaskingUtils.kt @@ -7,19 +7,21 @@ import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress +private const val IP_MASK_PLACEHOLDER = "*.*.*.*" + fun maskIp(ip: String): String { val normalized = ip.trim() val parsed = when { normalized.contains('.') -> { val parts = normalized.split('.') if (parts.size != 4 || parts.any { (it.toIntOrNull() ?: -1) !in 0..255 }) { - return "*.*.*.*" + return IP_MASK_PLACEHOLDER } runCatching { InetAddress.getByName(normalized) }.getOrNull() } normalized.contains(':') -> runCatching { InetAddress.getByName(normalized) }.getOrNull() else -> null - } ?: return "*.*.*.*" + } ?: return IP_MASK_PLACEHOLDER return when (parsed) { is Inet4Address -> { if (parsed.isSiteLocalAddress || parsed.isLoopbackAddress || parsed.isLinkLocalAddress) { @@ -41,7 +43,7 @@ fun maskIp(ip: String): String { } } } - else -> "*.*.*.*" + else -> IP_MASK_PLACEHOLDER } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt index 2c7c312..5f315ab 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppClassifier.kt @@ -46,7 +46,7 @@ object VpnAppClassifier { ) private val coreTypeMarkers = listOf( - CORE_TYPE_SING_BOX to listOf("sing-box", "singbox"), + CORE_TYPE_SING_BOX to listOf(CORE_TYPE_SING_BOX, "singbox"), CORE_TYPE_XRAY_V2RAY to listOf("xray", "v2ray", "v2fly"), CORE_TYPE_CLASH_MIHOMO to listOf("mihomo", "clash"), CORE_TYPE_WIREGUARD to listOf("wireguard"), diff --git a/app/src/main/res/layout/fragment_settings_appearance.xml b/app/src/main/res/layout/fragment_settings_appearance.xml index d1b41c4..d1ab14a 100644 --- a/app/src/main/res/layout/fragment_settings_appearance.xml +++ b/app/src/main/res/layout/fragment_settings_appearance.xml @@ -11,6 +11,71 @@ android:orientation="vertical" android:padding="16dp"> + + + + + + + + + + + + + + + + + + + روشن تیره سیستمی + جزئیات نتایج + ساده + معمولی + پیشرفته + خلاصه‌های ساده و قابل فهم، بدون جزئیات فنی. برای بررسی بعدی اعمال می‌شود. + نمای استاندارد نتایج و جزئیات. برای بررسی بعدی اعمال می‌شود. + نتایج استاندارد همراه با داده‌های فنی بازشدنی. برای بررسی بعدی اعمال می‌شود. + حالت نمایش ساده نتایج + حالت نمایش معمولی نتایج + حالت نمایش پیشرفته نتایج زبان سیستمی English @@ -591,7 +601,7 @@ همه درخواست‌ها غیرفعال پنهان‌سازی IP - %1$s \u00b7 %2$s + %1$s \u00b7 %2$s \u00b7 %3$s نشانه‌های بومی β @@ -639,6 +649,64 @@ برنامه در یک محفظه شبیه‌سازی/dual-app اجرا می‌شود (کاربر %1$d)؛ VPN در پروفایل اصلی قابل تشخیص نیست برنامه در یک پروفایل کاری مدیریت‌شده اجرا می‌شود؛ VPN در پروفایل شخصی بدون روت قابل تشخیص نیست + سیگنال VPN: %1$s + ویژگی سیستمی VPN شناسایی شد + فایل مخفی‌سازی VPN یافت شد + ویژگی فریمورک hook شناسایی شد + ویژگی DNS به VPN اشاره می‌کند + فایل ابزار VPN یافت شد + محیط LSPosed/Xposed شناسایی شد + اتصال TCP روی پورت VPN + سوکت UDP روی پورت VPN + رابط VPN IPv6 در /proc + مسیر از طریق رابط VPN + رکورد ARP روی رابط VPN + Forwarding IP فعال است + فیلتر reverse path غیرفعال است + اتصال برقرار به IP خصوصی روی پورت VPN + قوانین مسیریابی VPN یافت شد + queue discipline VPN شناسایی شد + آدرس‌های MAC پنهان در جدول همسایه + MSS به طور مشکوکی پایین (احتمالاً تونل) + setsockopt رهگیری شد (hook VPN) + تداخل پورت روی loopback (listener VPN) + دسترسی به نقشه‌های BPF netd + IP_RECVERR رهگیری شد (hook VPN) + رابط VPN از طریق sysfs نشت کرد + رابط VPN در getifaddrs() + رابط VPN در /sys/class/net + رابط VPN در RTM_GETLINK + رابط VPN IPv6 در /proc/net/if_inet6 + مسیر VPN IPv6 در /proc/net/ipv6_route + ترافیک VPN در /proc/net/dev + دسترسی به /proc/net/fib_trie مسدود شد (SELinux) + SO_BINDTODEVICE رابط VPN را نشت داد + inet_diag netlink مسدود شد (SELinux) + getsockname() IP VPN را نشت داد + قوانین مسیریابی VPN از طریق netlink + شمارش مسیرها و رابط‌های ناشناس + تداخل پورت UDP روی رابط فیزیکی + عدم تطابق bind probe و RTM_GETLINK + رابط VPN از طریق if_indextoname + تحلیل UDP PMTU و TCP MSS + ارسال UDP موفق (بدون مشکل PMTU) + ارسال UDP ناموفق (مشکل PMTU) + تشخیص path MTU عادی + تست traceroute (ICMP/TTL) + ARM CNTVCT timing oracle + Backpressure تحت بار مداوم + پشتیبانی GSO در دسترس نیست (فقط تشخیصی) + ارسال GSO ناموفق بود (فقط تشخیصی) + GSO پشتیبانی می‌شود (رابط عادی) + بررسی تایم‌استمپینگ سخت‌افزاری + یافت نشد + غیرقابل دسترس (بدون مجوز) + تشخیص‌دهنده VPN (عمیق) + کتابخانه تشخیص‌دهنده VPN بارگذاری نشد + کتابخانه تشخیص‌دهنده VPN بارگذاری نشد: %1$s + نشانه عمیق VPN یافت نشد + + به‌روزرسانی موجود است نسخه نصب‌شده: %1$s\nآخرین نسخه: %2$s @@ -932,4 +1000,111 @@ میلی‌ثانیه در دسترس در دسترس نیست + چه چیزی بررسی شد + نتیجه + معنای این نتیجه + در حال بررسی + مشکلی پیدا نشد + کمی تردید وجود دارد + یک مشکل پیدا شد + بررسی ممکن نشد + این بررسی غیرفعال است + نتیجه پس از پایان این بخش نمایش داده می‌شود. + نشانه‌های بررسی‌شده عادی به نظر می‌رسند. + این بخش اطلاعات کافی برنگرداند. بررسی‌های دیگر همچنان می‌توانند کامل شوند. + یک نشانه غیرعادی پیدا شد، اما به‌تنهایی برای تأیید مشکل کافی نیست. + نشانه غیرعادی در اطلاعات نشانی عمومی شبکه دیده شد. + نشانه غیرعادی در وضعیت شبکه دستگاه دیده شد. + نشانه غیرعادی به یک برنامه شبکه نصب‌شده یا فعال مربوط بود. + نشانه غیرعادی در سرویس محلی تغییر مسیر ترافیک دیده شد. + نشانه غیرعادی در مسیر ترافیک شبکه دیده شد. + نشانه غیرعادی در مسیر شبکه تماس‌ها دیده شد. + نشانه غیرعادی هنگام مقایسه اطلاعات شبکه و مکان دیده شد. + نشانه غیرعادی هنگام ارتباط با یک سرویس دوردست دیده شد. + نشانه غیرعادی در محیط سیستمی دستگاه دیده شد. + نشانه‌های مرتبط در چند بخش بررسی شبکه دیده شدند. + اطلاعات تکمیلی: این کارت به‌تنهایی رأی نهایی را تغییر نمی‌دهد. + + مکان ظاهری اتصال + یکسان بودن نشانی نزد سرویس‌ها + پاسخ سایت‌های دوردست + تنظیمات آشکار شبکه + نشانه‌های دیگر شبکه + نشانه‌های شبکه در سطح دستگاه + مسیر ترافیک تماس + سازگاری پاسخ‌های شبکه + مقایسه تأخیر اتصال + هماهنگی شبکه و مکان + خروج ترافیک از مسیری دیگر + دسترسی به سایت‌های انتخاب‌شده + + کشور و شبکه‌ای که از نشانی عمومی اتصال دیده می‌شود. + این‌که سرویس‌های مختلف نشانی عمومی سازگاری گزارش می‌کنند. + این‌که سرویس‌های دوردست انتخاب‌شده اطلاعات مورد انتظار را برمی‌گردانند. + تنظیمات شبکه‌ای که تغییر مسیر ترافیک را مستقیماً گزارش می‌کنند. + نشانه‌های پشتیبان در برنامه‌ها، رابط‌ها و پیکربندی شبکه. + نشانه‌های سطح دستگاه که پنهان‌کردن آن‌ها برای برنامه‌ها دشوارتر است. + این‌که ترافیک تماس از مسیر شبکه غیرمنتظره‌ای عبور می‌کند. + این‌که پاسخ‌های شبکه با سرویس مقصد سازگار هستند. + این‌که تأخیرهای اتصال به‌شکل غیرعادی متفاوت هستند. + این‌که اطلاعات شبکه محلی و مکان با هم سازگار هستند. + این‌که ترافیک می‌تواند خارج از مسیر محافظت‌شده مورد انتظار خارج شود. + این‌که سایت‌های انتخاب‌شده در این شبکه مطابق انتظار رفتار می‌کنند. + + یک نشانه مستقیم نشان داد که ترافیک می‌تواند از مسیر غیرمنتظره‌ای عبور کند. + چند مشاهده نشانی به‌گونه‌ای ناسازگارند که به تغییر مسیر ترافیک اشاره می‌کند. + مکان اتصال با مکان شبکه محلی سازگار نیست. + نشانی شبیه اتصال میزبانی‌شده یا واسطه‌ای است، اما نشانه‌های دیگر قطعی نیستند. + رأی از ترکیب مکان اتصال و نشانه‌های شبکه دستگاه به‌دست آمده است. + یک یا چند نشانه پشتیبان نیاز به توجه دارند، اما به‌تنهایی مشکل را ثابت نمی‌کنند. + رأی بر پایه نتایج ساخت‌یافته بررسی‌های کامل‌شده است. + + داده‌های فنی + باز کردن داده‌های فنی + بستن داده‌های فنی + برای این کارت داده فنی ثبت نشده است. + بدنه‌ای ثبت نشده است. + منبع: %1$s + هدف: %1$s + وضعیت: %1$s + مدت: %1$d میلی‌ثانیه + اندازه ذخیره‌شده: %1$s + کوتاه شده (اندازه اصلی: %1$s) + کوتاه شده: سقف کلی ۵۱۲ KiB برای این بررسی پر شد. + %1$d بایت + کپی بلوک + کپی داده‌های فنی از %1$s + داده‌های فنی کپی شد + سرویس‌های روسی تشخیص IP نشانی‌های عمومی متفاوتی گزارش کردند. + سرویس‌های بین‌المللی تشخیص IP نشانی‌های عمومی متفاوتی گزارش کردند. + سرویس‌های روسی و بین‌المللی نشانی‌های عمومی متفاوتی گزارش کردند. + سرویس‌ها خانواده‌های متفاوت نشانی را برگرداندند، بنابراین پاسخ‌ها مستقیماً قابل مقایسه نیستند. + بعضی سرویس‌های تشخیص نشانی عمومی پاسخ ندادند و مقایسه ناقص است. + هیچ سرویس تشخیص نشانی عمومی پاسخ قابل استفاده‌ای نداد. + سایت‌های آزمایش‌شده از طریق نشانی‌های عمومی متفاوتی باز شدند. + بعضی سایت‌های آزمایش‌شده برای مقایسه کامل داده کافی ندادند. + اطلاعات کشور یا مالک شبکه نشانی عمومی به VPN، پراکسی، میزبانی یا کشوری غیرمنتظره اشاره دارد. + اندروید گزارش می‌دهد که شبکه فعال از VPN عبور می‌کند. + یک برنامه اکنون اتصال VPN را برقرار نگه داشته است. + تنظیم سراسری هدایت ترافیک در اندروید فعال است. + یک سرویس محلی که می‌تواند ترافیک شبکه را هدایت کند روی دستگاه پاسخ می‌دهد. + یک رابط شبکه ویژه ترافیک تونلی فعال است. + جدول مسیریابی دستگاه ترافیک را از تونل یا دروازه‌ای غیرمنتظره عبور می‌دهد. + درخواست‌های نام دامنه از مسیر شبکه متفاوت یا هدایت‌شده‌ای پردازش می‌شوند. + ترافیک خارج از VPN از مسیر شبکه یا نشانی عمومی متفاوتی استفاده کرد. + پراکسی محلی احراز هویت خواست و هدف آن قابل تأیید نبود. + داده‌های سیم‌کارت، شبکه همراه و مکان نشانی عمومی مسیر یکسانی را نشان نمی‌دهند. + پاسخ بسته آزمایشی با بسته ارسال‌شده مطابقت نداشت. + زمان پاسخ نشان می‌دهد برخی مقصدها شاید از مسیر شبکه متفاوتی استفاده کنند. + بررسی تماس تلگرام مسیر شبکه غیرعادی یا ناقصی پیدا کرد. + بررسی تماس واتس‌اپ مسیر شبکه غیرعادی یا ناقصی پیدا کرد. + سرویس‌های بررسی تماس داده کافی برای بررسی مسیر ندادند. + نام سایت برخلاف نتیجه مورد انتظار حل شد. + اتصال به سایت برخلاف نتیجه مورد انتظار برقرار شد. + اتصال امن برخلاف نتیجه مورد انتظار کامل شد. + سرویس‌های تشخیص نشانی عمومی داده کافی برنگرداندند. + اندروید برای تکمیل این بررسی داده کافی از وضعیت شبکه نداد. + سرویس‌های آزمایشی راه دور برای تکمیل این بررسی داده کافی ندادند. + اندروید برای تکمیل این بررسی داده سیستمی کافی از شبکه نداد. + در محیط دستگاه تغییر یا محدودیتی وجود دارد که می‌تواند بررسی‌های شبکه را تغییر دهد. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7741fe9..6aa8681 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -95,6 +95,16 @@ Светлая Тёмная Системная + Подробность результатов + Простой + Обычный + Расширенный + Понятные краткие итоги без технических подробностей. Применится к следующей проверке. + Стандартный вид и состав результатов. Применится к следующей проверке. + Обычные результаты с раскрываемыми техническими данными. Применится к следующей проверке. + Простой режим отображения результатов + Обычный режим отображения результатов + Расширенный режим отображения результатов Язык Системный English @@ -581,7 +591,7 @@ Все запросы Отключено Маскировка IP - %1$s \u00b7 %2$s + %1$s \u00b7 %2$s \u00b7 %3$s Нативные признаки β @@ -660,6 +670,65 @@ Приложение работает в клонированном/dual-app контейнере (пользователь %1$d); VPN в основном профиле не обнаруживается Приложение работает в управляемом рабочем профиле; VPN в личном профиле не обнаруживается без root + + VPN сигнал: %1$s + Обнаружены VPN-свойства системы + Файл сокрытия VPN + Свойства хук-фреймворков + DNS-свойства указывают на VPN + Файлы VPN-инструментов + Обнаружена LSPosed/Xposed среда + TCP-соединение на VPN-порту + UDP-сокет на VPN-порту + IPv6-интерфейс VPN в /proc + Маршрут через VPN-интерфейс + ARP-запись VPN-интерфейса + IP-форвардинг включён + Reverse path filter отключен + Установленное соединение к приватному IP через VPN-порт + Найдены VPN-правила роутинга + Обнаружена VPN queue discipline + Скрытые MAC-адреса в таблице соседей + Подозрительно низкий MSS (возможно туннель) + Перехват setsockopt (VPN-хук) + Конфликт портов на loopback (VPN listener) + Доступ к BPF-картам netd + Перехват IP_RECVERR (VPN-хук) + VPN-интерфейс утёк через sysfs + VPN-интерфейс в getifaddrs() + VPN-интерфейс в /sys/class/net + VPN-интерфейс в RTM_GETLINK + VPN IPv6-интерфейс в /proc/net/if_inet6 + VPN IPv6-маршрут в /proc/net/ipv6_route + VPN-трафик в /proc/net/dev + Доступ к /proc/net/fib_trie запрещён (SELinux) + SO_BINDTODEVICE утек интерфейс VPN + inet_diag netlink запрещён (SELinux) + getsockname() утек IP VPN + VPN-правила роутинга через netlink + Подсчёт маршрутов и анонимных интерфейсов + Конфликт UDP-порта на физическом интерфейсе + Расхождение bind probe и RTM_GETLINK + VPN-интерфейс через if_indextoname + Анализ UDP PMTU и TCP MSS + UDP-отправка успешна (нет PMTU-проблемы) + UDP-отправка не удалась (PMTU-проблема) + Обнаружение нормального path MTU + Traceroute-проба (ICMP/TTL) + ARM CNTVCT timing oracle + Backpressure при постоянной нагрузке + Поддержка GSO недоступна (только диагностика) + GSO-отправка не удалась (только диагностика) + GSO поддерживается (нормальный интерфейс) + Проверка аппаратного таймстампинга + не обнаружено + недоступно (нет прав) + VPN Детектор (глубокий) + Библиотека VPN-детектора не загружена + Библиотека VPN-детектора не загружена: %1$s + Глубоких признаков VPN не обнаружено + + Доступно обновление Установленная версия: %1$s\nАктуальная версия: %2$s @@ -953,4 +1022,111 @@ мс доступен недоступен + Что проверялось + Результат + Что это значит + Идёт проверка + Проблем не найдено + Есть сомнение + Обнаружена проблема + Не удалось проверить + Эта проверка отключена + Результат появится после завершения этой части проверки. + Проверенные признаки выглядят обычно. + Эта часть не дала достаточно данных. Остальные проверки могут продолжиться. + Найден необычный признак, но его недостаточно, чтобы подтвердить проблему. + Необычный признак найден в данных о публичном сетевом адресе. + Необычный признак найден в состоянии сети устройства. + Необычный признак связан с установленным или активным сетевым приложением. + Необычный признак найден в локальной службе перенаправления трафика. + Необычный признак найден на пути сетевого трафика. + Необычный признак найден на сетевом пути звонков. + Необычный признак найден при сравнении данных сети и местоположения. + Необычный признак найден при обращении к удалённому сервису. + Необычный признак найден в системном окружении устройства. + Связанные признаки найдены в нескольких частях проверки сети. + Дополнительная информация: эта карточка сама по себе не меняет итоговый вердикт. + + Где находится подключение + Одинаковый ли адрес видят сервисы + Как отвечают удалённые сайты + Видимые настройки сети + Другие сетевые признаки + Системные сетевые признаки + Путь трафика звонков + Согласованность сетевых ответов + Сравнение задержек подключения + Совпадение сети и местоположения + Выход трафика другим путём + Доступность выбранных сайтов + + Страна и сеть, которые видны по публичному адресу подключения. + Показывают ли разные сервисы согласованный публичный адрес. + Возвращают ли выбранные удалённые сервисы ожидаемые данные. + Настройки сети, прямо сообщающие о перенаправлении трафика. + Дополнительные признаки в приложениях, интерфейсах и настройках сети. + Системные признаки, которые приложениям сложнее скрыть. + Идёт ли трафик звонков по неожиданному сетевому пути. + Согласуются ли сетевые ответы с сервисом, к которому обращается приложение. + Отличаются ли задержки подключения необычным образом. + Согласуются ли данные локальной сети и местоположения. + Может ли трафик выйти вне ожидаемого защищённого пути. + Ведут ли себя выбранные сайты ожидаемо в этой сети. + + Прямой признак показал, что трафик может идти неожиданным путём. + Несколько наблюдений адреса расходятся так, что это указывает на перенаправление трафика. + Местоположение подключения противоречит местоположению локальной сети. + Адрес похож на серверное или пересылаемое подключение, но остальные признаки не дают уверенного ответа. + Вердикт основан на сочетании местоположения подключения и сетевых признаков устройства. + Один или несколько дополнительных признаков требуют внимания, но сами по себе не подтверждают проблему. + Вердикт основан на структурированных результатах завершённых проверок. + + Технические данные + Развернуть технические данные + Свернуть технические данные + Для этой карточки технические записи не собраны. + Тело ответа не сохранено. + Источник: %1$s + Цель: %1$s + Статус: %1$s + Длительность: %1$d мс + Сохранённый размер: %1$s + Обрезано (исходный размер: %1$s) + Обрезано: достигнут общий лимит 512 КиБ на проверку. + %1$d Б + Копировать блок + Копировать технические данные из %1$s + Технические данные скопированы + Российские сервисы определения IP сообщили разные публичные адреса. + Зарубежные сервисы определения IP сообщили разные публичные адреса. + Российские и зарубежные сервисы сообщили разные публичные адреса. + Сервисы вернули адреса разных семейств, поэтому ответы нельзя сравнить напрямую. + Часть сервисов определения публичного адреса не ответила, поэтому сравнение неполное. + Ни один сервис определения публичного адреса не вернул пригодный ответ. + Проверенные сайты открылись через разные публичные адреса. + Часть проверенных сайтов не вернула достаточно данных для полного сравнения. + Данные о стране или владельце публичного адреса указывают на VPN, прокси, хостинг либо неожиданную страну. + Android сообщает, что активная сеть работает через VPN. + Одно из приложений сейчас поддерживает VPN-подключение. + В Android включена системная настройка перенаправления интернет-трафика. + На устройстве отвечает локальная служба, способная перенаправлять сетевой трафик. + Активен сетевой интерфейс, созданный для туннельного трафика. + Таблица маршрутов устройства направляет трафик через туннель или неожиданный шлюз. + Запросы доменных имён обрабатываются через другой или перенаправленный сетевой путь. + Трафик вне VPN прошёл по другому сетевому пути или получил другой публичный адрес. + Локальный прокси запросил вход, поэтому подтвердить его назначение не удалось. + Данные SIM-карты, мобильной сети и страны публичного адреса описывают разные маршруты. + На тестовый пакет пришёл ответ, который не совпадает с отправленным пакетом. + Время ответа показывает, что часть адресов может использовать другой сетевой маршрут. + Проверка связи для звонков Telegram обнаружила необычный или неполный сетевой путь. + Проверка связи для звонков WhatsApp обнаружила необычный или неполный сетевой путь. + Сервисы проверки связи для звонков не вернули достаточно данных о маршруте. + Имя сайта разрешилось не так, как ожидалось. + Соединение с сайтом установилось не так, как ожидалось. + Защищённое соединение завершилось не так, как ожидалось. + Сервисы определения публичного адреса не вернули достаточно данных. + Android не предоставил достаточно данных о состоянии сети для этой проверки. + Удалённые тестовые сервисы не вернули достаточно данных для этой проверки. + Android не предоставил достаточно системных данных о сети для этой проверки. + В среде устройства есть изменения или ограничения, способные повлиять на сетевые проверки. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0832235..414d57b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -95,6 +95,16 @@ 浅色 深色 跟随系统 + 结果详细程度 + 简洁 + 普通 + 高级 + 使用易懂的摘要,不显示技术细节。将在下次检查时应用。 + 使用标准的结果视图和详细信息。将在下次检查时应用。 + 显示标准结果和可展开的技术数据。将在下次检查时应用。 + 简洁结果显示模式 + 普通结果显示模式 + 高级结果显示模式 语言 系统 English @@ -589,7 +599,7 @@ 所有请求 已禁用 IP 掩码 - %1$s \u00b7 %2$s + %1$s \u00b7 %2$s \u00b7 %3$s 原生检查 β @@ -637,6 +647,64 @@ 应用运行在克隆/双开容器中(用户 %1$d);无法检测主资料中的 VPN 应用运行在受管理的工作资料中;无法在无 root 的情况下检测个人资料中的 VPN + VPN 信号:%1$s + 检测到 VPN 系统属性 + 发现 VPN 隐藏文件 + 检测到 hook 框架属性 + DNS 属性指向 VPN + 发现 VPN 工具文件 + 检测到 LSPosed/Xposed 环境 + VPN 端口上的 TCP 连接 + VPN 端口上的 UDP 套接字 + /proc 中的 IPv6 VPN 接口 + 通过 VPN 接口的路由 + VPN 接口上的 ARP 条目 + IP 转发已启用 + 反向路径过滤已禁用 + 到私有 IP 的 VPN 端口已建立连接 + 发现 VPN 路由规则 + 检测到 VPN 队列规则 + 邻居表中隐藏的 MAC 地址 + 异常低的 MSS(可能是隧道) + setsockopt 被拦截(VPN hook) + loopback 端口冲突(VPN 监听器) + 访问 netd BPF 映射 + IP_RECVERR 被拦截(VPN hook) + VPN 接口通过 sysfs 泄露 + getifaddrs() 中的 VPN 接口 + /sys/class/net 中的 VPN 接口 + RTM_GETLINK 中的 VPN 接口 + /proc/net/if_inet6 中的 VPN IPv6 接口 + /proc/net/ipv6_route 中的 VPN IPv6 路由 + /proc/net/dev 中的 VPN 流量 + /proc/net/fib_trie 访问被拒绝 (SELinux) + SO_BINDTODEVICE 泄露 VPN 接口 + inet_diag netlink 被拒绝 (SELinux) + getsockname() 泄露 VPN IP + 通过 netlink 的 VPN 策略规则 + 路由计数和匿名接口 + 物理接口上的 UDP 端口冲突 + bind probe 与 RTM_GETLINK 不符 + 通过 if_indextoname 暴露的 VPN 接口 + UDP PMTU 和 TCP MSS 分析 + UDP 发送成功(无 PMTU 瓶颈) + UDP 发送失败(PMTU 问题) + 正常路径 MTU 检测 + Traceroute 探测 (ICMP/TTL) + ARM CNTVCT 计时神谕 + 持续突发下的背压 + GSO 支持不可用(仅诊断) + GSO 发送失败(仅诊断) + 支持 GSO(正常接口) + 硬件时间戳检查 + 未发现 + 不可用(无权限) + VPN 检测器(深度) + VPN 检测器库未加载 + VPN 检测器库未加载:%1$s + 未检测到深度 VPN 迹象 + + 有更新可用 已安装版本:%1$s\n最新版本:%2$s @@ -930,4 +998,111 @@ 毫秒 可用 不可用 + 检查内容 + 结果 + 结果含义 + 正在检查 + 未发现问题 + 存在疑点 + 发现问题 + 无法检查 + 此检查已关闭 + 这部分检查完成后会显示结果。 + 已检查的信号看起来正常。 + 这部分未返回足够的信息,其他检查仍可继续完成。 + 检查发现异常信号,但仅凭这一项不足以确认问题。 + 异常信号出现在公网地址信息中。 + 异常信号出现在设备网络状态中。 + 异常信号与已安装或正在运行的网络应用有关。 + 异常信号出现在本地流量转发服务中。 + 异常信号出现在网络流量路径中。 + 异常信号出现在通话使用的网络路径中。 + 比较网络与位置信息时发现异常信号。 + 连接远程服务时发现异常信号。 + 异常信号出现在设备系统环境中。 + 网络检查的多个部分出现了相关信号。 + 补充信息:此卡片本身不会改变最终结论。 + + 连接显示的位置 + 各服务看到的地址是否一致 + 远程网站如何响应 + 可见的网络设置 + 其他网络信号 + 设备级网络信号 + 通话流量路径 + 网络响应一致性 + 连接延迟对比 + 网络与位置是否一致 + 流量是否从其他路径离开 + 所选网站是否可访问 + + 公网连接地址所显示的国家和网络。 + 不同服务是否报告一致的公网地址。 + 所选远程服务是否返回预期信息。 + 直接表明流量被转发的网络设置。 + 应用、接口和网络配置中的辅助信号。 + 应用较难隐藏的设备级信号。 + 通话流量是否经过意外的网络路径。 + 网络响应是否与所联系的服务一致。 + 连接延迟是否以异常方式出现差异。 + 本地网络与位置信息是否一致。 + 流量是否能离开预期的受保护路径。 + 所选网站在当前网络上是否按预期工作。 + + 直接信号表明流量可以经过意外路径。 + 多项地址观察存在冲突,表明流量可能被转发。 + 连接位置与本地网络位置冲突。 + 该地址类似托管或中转连接,但其他信号尚无定论。 + 结论基于连接位置与设备网络信号的组合。 + 一个或多个辅助信号需要关注,但单独不足以证明问题。 + 结论基于已完成检查的结构化结果。 + + 技术数据 + 展开技术数据 + 收起技术数据 + 此卡片没有记录技术条目。 + 未记录正文。 + 来源:%1$s + 目标:%1$s + 状态:%1$s + 耗时:%1$d 毫秒 + 保存大小:%1$s + 已截断(原始大小:%1$s) + 已截断:本次检查已达到 512 KiB 总上限。 + %1$d 字节 + 复制区块 + 复制来自 %1$s 的技术数据 + 已复制技术数据 + 俄罗斯 IP 查询服务报告了不同的公网地址。 + 国际 IP 查询服务报告了不同的公网地址。 + 俄罗斯和国际服务报告了不同的公网地址。 + 服务返回了不同的地址类型,因此无法直接比较这些结果。 + 部分公网地址查询服务没有响应,因此比较结果不完整。 + 所有公网地址查询服务都没有返回可用结果。 + 受测网站通过不同的公网地址访问。 + 部分受测网站没有返回足够数据,无法完成比较。 + 公网地址的国家或网络归属信息指向 VPN、代理、托管网络或意外的国家。 + Android 报告当前网络通过 VPN 传输。 + 某个应用当前正在维持 VPN 连接。 + Android 已启用全局流量转发设置。 + 设备上的本地流量转发服务正在响应。 + 用于隧道流量的网络接口处于活动状态。 + 设备路由表将流量发送到隧道或意外的网关。 + 域名请求通过不同或重定向的网络路径处理。 + VPN 外的流量使用了不同的网络路径或公网地址。 + 本地代理要求身份验证,因此无法确认其用途。 + SIM 卡、移动网络和公网地址位置数据描述了不同的路径。 + 测试数据包收到的响应与发出的数据包不一致。 + 响应时间表明部分目标可能使用了不同的网络路径。 + Telegram 通话连接检查发现了异常或不完整的网络路径。 + WhatsApp 通话连接检查发现了异常或不完整的网络路径。 + 通话连接服务没有返回足够数据来检查路径。 + 网站名称的解析结果与预期不同。 + 与网站建立连接的结果与预期不同。 + 安全连接的完成结果与预期不同。 + 公网地址查询服务没有返回足够数据。 + Android 没有提供足够的网络状态数据来完成此检查。 + 远程测试服务没有返回足够数据来完成此检查。 + Android 没有提供足够的设备级网络数据来完成此检查。 + 设备环境中存在可能影响网络检查的修改或限制。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 040c928..4f92f85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -94,6 +94,16 @@ Light Dark System + Result details + Simple + Normal + Advanced + Plain-language summaries without technical details. Applies to the next check. + The standard result view and details. Applies to the next check. + Standard results with expandable technical data. Applies to the next check. + Simple result display mode + Normal result display mode + Advanced result display mode Language System English @@ -577,7 +587,7 @@ All requests Disabled IP masking - %1$s \u00b7 %2$s + %1$s \u00b7 %2$s \u00b7 %3$s Native signs β @@ -665,6 +675,65 @@ App runs in a cloned/dual-app container (user %1$d); a VPN in the primary profile is not observable App runs in a managed work profile; a VPN in the personal profile is not observable without root + + VPN signal: %1$s + VPN system property detected + VPN hide file found + Hook framework property detected + DNS property points to VPN + VPN tool file found + LSPosed/Xposed environment detected + TCP connection on VPN port + UDP socket on VPN port + IPv6 VPN interface in /proc + Route via VPN interface + ARP entry on VPN interface + IP forwarding is enabled + Reverse path filter disabled + Established connection to private IP on VPN port + VPN routing rules found + VPN queue discipline detected + Hidden MAC addresses in neighbor table + Suspiciously low MSS (possible tunnel) + setsockopt intercepted (VPN hook) + Port conflict on loopback (VPN listener) + Access to netd BPF maps + IP_RECVERR intercepted (VPN hook) + VPN interface leaked via sysfs + VPN interface in getifaddrs() + VPN interface in /sys/class/net + VPN interface in RTM_GETLINK + VPN IPv6 interface in /proc/net/if_inet6 + VPN IPv6 route in /proc/net/ipv6_route + VPN traffic in /proc/net/dev + /proc/net/fib_trie access denied (SELinux) + SO_BINDTODEVICE leaked VPN interface + inet_diag netlink denied (SELinux) + getsockname() leaked VPN IP + VPN policy rules via netlink + Route count and anonymous interfaces + UDP port conflict on physical interface + Bind probe vs RTM_GETLINK discrepancy + VPN interface exposed via if_indextoname + UDP PMTU and TCP MSS analysis + UDP send succeeded (no PMTU bottleneck) + UDP send failed (PMTU issue) + Normal path MTU detection + Traceroute probe (ICMP/TTL) + ARM CNTVCT timing oracle + Backpressure under sustained burst + GSO support unavailable (diagnostic only) + GSO send failed (diagnostic only) + GSO supported (normal interface) + Hardware timestamping check + not found + unavailable (no permission) + VPN Detector (deep) + VPN detector library not loaded + VPN detector library not loaded: %1$s + No deep VPN signs detected + + Update available Installed version: %1$s\nLatest version: %2$s @@ -965,4 +1034,111 @@ ms available unavailable + What was checked + Result + What this means + Checking now + No problems found + There is some uncertainty + A problem was found + Could not check + This check is turned off + The result will appear when this part of the check finishes. + The checked signs looked normal. + This part did not return enough information. Other checks can still finish. + The check found an unusual sign, but it is not enough on its own to confirm a problem. + The unusual sign appeared in the public network address information. + The unusual sign appeared in the device network state. + The unusual sign was linked to an installed or active network app. + The unusual sign appeared in a local traffic redirection service. + The unusual sign appeared in the path used by network traffic. + The unusual sign appeared in the network path used for calls. + The unusual sign appeared when network and location information were compared. + The unusual sign appeared while contacting a remote service. + The unusual sign appeared in the device system environment. + Related signs appeared in more than one part of the network check. + Additional information: this card does not change the final verdict by itself. + + Where the connection appears to be + Whether services see the same address + How remote sites answer + Visible network settings + Other network signs + Device-level network signs + Call traffic path + Network reply consistency + Connection delay comparison + Network and location match + Traffic leaving by another path + Whether selected sites are reachable + + The country and network shown by the public connection address. + Whether different services report a consistent public address. + Whether selected remote services return the expected information. + Network settings that directly report traffic redirection. + Supporting signs in apps, interfaces and network configuration. + Device-level signs that are harder for apps to hide. + Whether call traffic follows an unexpected network path. + Whether network replies look consistent with the contacted service. + Whether connection delays differ in an unusual way. + Whether local network and location information agree. + Whether traffic can leave outside the expected protected path. + Whether selected sites behave as expected on this network. + + A direct sign showed that traffic can use an unexpected path. + Several address observations disagree in a way that points to traffic redirection. + The connection location conflicts with the local network location. + The address resembles a hosted or relayed connection, but the other signs are not conclusive. + The verdict is based on the combined connection location and device network signs. + One or more supporting signs need attention, but they do not prove a problem by themselves. + The verdict is based on the structured results of the completed checks. + + Technical data + Expand technical data + Collapse technical data + No technical entries were recorded for this card. + No body was recorded. + Source: %1$s + Target: %1$s + Status: %1$s + Duration: %1$d ms + Stored size: %1$s + Truncated (original size: %1$s) + Truncated: the scan-wide 512 KiB limit was reached. + %1$d B + Copy block + Copy technical data from %1$s + Technical data copied + Russian IP services reported different public addresses. + International IP services reported different public addresses. + Russian and international services reported different public addresses. + The services returned different address families, so the answers cannot be compared directly. + Some public-address services did not answer, so the comparison is incomplete. + No public-address service returned a usable answer. + The tested sites were reached through different public addresses. + Some tested sites did not return enough data for a complete comparison. + Public-address location or network ownership data indicates a VPN, proxy, hosting network, or an unexpected country. + Android reports that the active network is carried through a VPN. + An application is currently maintaining the VPN connection. + Android has a system-wide traffic redirection setting enabled. + A local service that can redirect network traffic is responding on the device. + A network interface created for tunneled traffic is active. + The device routing table sends traffic through a tunnel or an unexpected gateway. + Domain-name requests are handled through a different or redirected network path. + Traffic sent outside the VPN used a different network path or public address. + A local proxy asked for authentication, so its purpose could not be confirmed. + SIM, mobile network, and public-address location data do not describe the same route. + A test packet received a response that did not match the packet that was sent. + Response times indicate that some destinations may be using a different network route. + The Telegram call connectivity check found an unusual or incomplete network path. + The WhatsApp call connectivity check found an unusual or incomplete network path. + The call connectivity services did not return enough data to check the route. + The domain name resolved differently from the expected result. + The connection to the service opened differently from the expected result. + The secure connection completed differently from the expected result. + The services used to determine the public address did not return enough data. + Android did not provide enough network-state data to finish this check. + The remote test services did not return enough data to finish this check. + Android did not provide enough device-level network data to finish this check. + The device environment contains changes or restrictions that can alter network checks. diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt index f43248c..a51931a 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatterTest.kt @@ -5,6 +5,10 @@ import androidx.test.core.app.ApplicationProvider import com.notcvnt.rknhardering.export.CheckResultJsonExportFormatter import com.notcvnt.rknhardering.export.CompletedExportSnapshot import com.notcvnt.rknhardering.export.createCompletedExportSnapshot +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult import org.json.JSONObject @@ -61,6 +65,60 @@ class CheckResultJsonExportFormatterTest { assertFalse(outbound.has("publicKey")) } + @Test + fun `json export includes deep native detector findings and evidence`() { + val result = exportRichCheckResult().copy( + nativeSigns = CategoryResult( + name = "Native signs", + detected = true, + findings = listOf( + Finding( + description = "backpressure: 50000 packets", + isInformational = true, + source = EvidenceSource.NATIVE_ROUTE, + confidence = EvidenceConfidence.LOW, + ), + Finding( + description = "bindtodevice_leak: tun0", + detected = true, + source = EvidenceSource.NATIVE_SOCKET, + confidence = EvidenceConfidence.HIGH, + ), + ), + evidence = listOf( + EvidenceItem( + source = EvidenceSource.NATIVE_SOCKET, + detected = true, + confidence = EvidenceConfidence.HIGH, + description = "bindtodevice_leak: tun0", + ), + ), + ), + ) + + val json = JSONObject( + CheckResultJsonExportFormatter.format( + context = context, + snapshot = createCompletedExportSnapshot( + result = result, + privacyMode = false, + finishedAtMillis = 0L, + ), + appVersionName = "1.0", + buildType = "debug", + ), + ) + val nativeSigns = json.getJSONObject("results").getJSONObject("nativeSigns") + val findings = nativeSigns.getJSONArray("findings") + val evidence = nativeSigns.getJSONArray("evidence") + + assertEquals("backpressure: 50000 packets", findings.getJSONObject(0).getString("description")) + assertTrue(findings.getJSONObject(0).getBoolean("isInformational")) + assertEquals("bindtodevice_leak: tun0", findings.getJSONObject(1).getString("description")) + assertEquals("NATIVE_SOCKET", evidence.getJSONObject(0).getString("source")) + assertEquals("bindtodevice_leak: tun0", evidence.getJSONObject(0).getString("description")) + } + @Test fun `json export marks ip comparison and bypass errors`() { val base = exportEmptyCheckResult() diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt index ddcb5c3..8c3516d 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckResultMarkdownExportFormatterTest.kt @@ -5,6 +5,10 @@ import androidx.test.core.app.ApplicationProvider import com.notcvnt.rknhardering.export.CheckResultMarkdownExportFormatter import com.notcvnt.rknhardering.export.CompletedExportSnapshot import com.notcvnt.rknhardering.export.createCompletedExportSnapshot +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.UnparsedIp @@ -45,6 +49,54 @@ class CheckResultMarkdownExportFormatterTest { assertTrue(markdown.contains("## Footer")) } + @Test + fun `markdown export includes deep native detector findings and evidence`() { + val result = exportRichCheckResult().copy( + nativeSigns = CategoryResult( + name = "Native signs", + detected = true, + findings = listOf( + Finding( + description = "backpressure: 50000 packets", + isInformational = true, + source = EvidenceSource.NATIVE_ROUTE, + confidence = EvidenceConfidence.LOW, + ), + Finding( + description = "bindtodevice_leak: tun0", + detected = true, + source = EvidenceSource.NATIVE_SOCKET, + confidence = EvidenceConfidence.HIGH, + ), + ), + evidence = listOf( + EvidenceItem( + source = EvidenceSource.NATIVE_SOCKET, + detected = true, + confidence = EvidenceConfidence.HIGH, + description = "bindtodevice_leak: tun0", + ), + ), + ), + ) + + val markdown = CheckResultMarkdownExportFormatter.format( + context = context, + snapshot = createCompletedExportSnapshot( + result = result, + privacyMode = false, + finishedAtMillis = 0L, + ), + appVersionName = "1.0", + buildType = "debug", + ) + + assertTrue(markdown.contains("backpressure: 50000 packets | informational=true")) + assertTrue(markdown.contains("bindtodevice_leak: tun0 | detected=true | source=NATIVE_SOCKET")) + assertTrue(markdown.contains("source=NATIVE_SOCKET | detected=true | confidence=HIGH")) + assertTrue(markdown.contains("description=bindtodevice_leak: tun0")) + } + @Test fun `markdown export marks ip comparison and bypass errors`() { val base = exportEmptyCheckResult() diff --git a/app/src/test/java/com/notcvnt/rknhardering/CheckViewModelTest.kt b/app/src/test/java/com/notcvnt/rknhardering/CheckViewModelTest.kt index 3efb156..cb44506 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/CheckViewModelTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/CheckViewModelTest.kt @@ -13,6 +13,7 @@ import com.notcvnt.rknhardering.model.IpCheckerGroupResult import com.notcvnt.rknhardering.model.IpComparisonResult import com.notcvnt.rknhardering.model.Verdict import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -98,6 +99,63 @@ class CheckViewModelTest { assertTrue(events[1] is ScanEvent.Cancelled) } + @Test + fun `scan timeline keeps captured display mode and next scan uses changed preference`() { + val firstScanMayComplete = CompletableDeferred() + val capturedContexts = mutableListOf() + val app: Application = ApplicationProvider.getApplicationContext() + val prefs = AppUiSettings.prefs(app) + prefs.edit().clear().putString( + SettingsPrefs.PREF_RESULT_DISPLAY_MODE, + ResultDisplayMode.SIMPLE.prefValue, + ).commit() + val viewModel = CheckViewModel(app) + + CheckViewModel.runScanOverride = { _, _, executionContext, _ -> + capturedContexts += executionContext + if (capturedContexts.size == 1) firstScanMayComplete.await() + completedResult() + } + + viewModel.startScan( + settings = CheckSettings( + splitTunnelEnabled = false, + networkRequestsEnabled = false, + ), + privacyMode = true, + ) + shadowOf(Looper.getMainLooper()).idle() + + val started = viewModel.scanEvents.value.events.single() as ScanEvent.Started + assertEquals(ResultDisplayMode.SIMPLE, started.resultDisplayMode) + assertEquals(ResultDisplayMode.SIMPLE, capturedContexts.single().resultDisplayMode) + assertEquals(null, capturedContexts.single().diagnosticCollector) + + prefs.edit().putString( + SettingsPrefs.PREF_RESULT_DISPLAY_MODE, + ResultDisplayMode.ADVANCED.prefValue, + ).commit() + assertEquals(ResultDisplayMode.SIMPLE, started.resultDisplayMode) + + firstScanMayComplete.complete(Unit) + shadowOf(Looper.getMainLooper()).idle() + + val firstCompleted = viewModel.scanEvents.value.events.last() as ScanEvent.Completed + assertEquals(ResultDisplayMode.SIMPLE, firstCompleted.resultDisplayMode) + + viewModel.startScan( + settings = started.settings, + privacyMode = true, + ) + shadowOf(Looper.getMainLooper()).idle() + + val secondEvents = viewModel.scanEvents.value.events + assertEquals(ResultDisplayMode.ADVANCED, (secondEvents.first() as ScanEvent.Started).resultDisplayMode) + assertEquals(ResultDisplayMode.ADVANCED, (secondEvents.last() as ScanEvent.Completed).resultDisplayMode) + assertEquals(ResultDisplayMode.ADVANCED, capturedContexts.last().resultDisplayMode) + assertTrue(capturedContexts.last().diagnosticCollector != null) + } + private fun category(name: String): CategoryResult = CategoryResult( name = name, detected = false, diff --git a/app/src/test/java/com/notcvnt/rknhardering/ResultDisplayModeTest.kt b/app/src/test/java/com/notcvnt/rknhardering/ResultDisplayModeTest.kt new file mode 100644 index 0000000..637add1 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/ResultDisplayModeTest.kt @@ -0,0 +1,21 @@ +package com.notcvnt.rknhardering + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ResultDisplayModeTest { + + @Test + fun `missing and unknown preference fall back to normal`() { + assertEquals(ResultDisplayMode.NORMAL, ResultDisplayMode.fromPref(null)) + assertEquals(ResultDisplayMode.NORMAL, ResultDisplayMode.fromPref("")) + assertEquals(ResultDisplayMode.NORMAL, ResultDisplayMode.fromPref("unexpected")) + } + + @Test + fun `all preference values round trip`() { + ResultDisplayMode.entries.forEach { mode -> + assertEquals(mode, ResultDisplayMode.fromPref(mode.prefValue)) + } + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/ResultDisplayModeUiTest.kt b/app/src/test/java/com/notcvnt/rknhardering/ResultDisplayModeUiTest.kt new file mode 100644 index 0000000..cc4ee05 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/ResultDisplayModeUiTest.kt @@ -0,0 +1,314 @@ +package com.notcvnt.rknhardering + +import android.content.Context +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import com.google.android.material.button.MaterialButton +import com.notcvnt.rknhardering.checker.CheckSettings +import com.notcvnt.rknhardering.checker.CheckUpdate +import com.notcvnt.rknhardering.diagnostics.DiagnosticEntry +import com.notcvnt.rknhardering.diagnostics.DiagnosticSnapshot +import com.notcvnt.rknhardering.model.BypassResult +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.CheckResult +import com.notcvnt.rknhardering.model.Finding +import com.notcvnt.rknhardering.model.IpCheckerGroupResult +import com.notcvnt.rknhardering.model.IpCheckerResponse +import com.notcvnt.rknhardering.model.IpCheckerScope +import com.notcvnt.rknhardering.model.IpComparisonResult +import com.notcvnt.rknhardering.model.Verdict +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class ResultDisplayModeUiTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + AppUiSettings.prefs(context).edit().clear().commit() + } + + @After + fun tearDown() { + CheckViewModel.runScanOverride = null + } + + @Test + fun `normal mode keeps existing technical finding presentation`() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + invokePrivate(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.NORMAL) + invokePrivate( + activity, + "applyScanEvent", + ScanEvent.Update(CheckUpdate.DirectSignsReady(technicalCategory())), + false, + ) + invokePrivate(activity, "expandCategory", "dir") + + assertTrue(collectVisibleText(activity.findViewById(R.id.bodyDirect)).contains(TECHNICAL_MARKER)) + } + + @Test + fun `simple mode keeps enabled cards but hides technical strings`() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + invokePrivate(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.SIMPLE) + invokePrivate( + activity, + "applyScanEvent", + ScanEvent.Update(CheckUpdate.DirectSignsReady(technicalCategory())), + false, + ) + invokePrivate(activity, "expandCategory", "dir") + + assertEquals(View.VISIBLE, activity.findViewById(R.id.cardDirect).visibility) + val visibleText = collectVisibleText(activity.findViewById(R.id.bodyDirect)) + assertFalse(visibleText.contains(TECHNICAL_MARKER)) + assertTrue(visibleText.contains(activity.getString(R.string.simple_result_checked_label))) + } + + @Test + fun `simple mode names the exact ip group mismatch without technical data`() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + invokePrivate(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.SIMPLE) + val rawSummary = "technical summary 87.116.181.251 vs 87.116.162.101" + val ruGroup = IpCheckerGroupResult( + title = "RU", + detected = true, + statusLabel = "raw enum-like status", + summary = rawSummary, + responses = listOf( + ipResponse("87.116.181.251", IpCheckerScope.RU), + ipResponse("87.116.162.101", IpCheckerScope.RU), + ), + ) + val nonRuGroup = group("non-ru") + + invokePrivate( + activity, + "applyScanEvent", + ScanEvent.Update( + CheckUpdate.IpComparisonReady( + IpComparisonResult( + detected = false, + needsReview = true, + summary = rawSummary, + ruGroup = ruGroup, + nonRuGroup = nonRuGroup, + ), + ), + ), + false, + ) + invokePrivate(activity, "expandCategory", "ipc") + + val visibleText = collectVisibleText(activity.findViewById(R.id.bodyIpComparison)) + assertTrue(visibleText.contains(activity.getString(R.string.simple_cause_ip_ru_services_disagree))) + assertFalse(visibleText.contains(rawSummary)) + assertFalse(visibleText.contains("87.116.181.251")) + assertFalse(visibleText.contains("87.116.162.101")) + assertFalse(visibleText.contains(activity.getString(R.string.simple_result_area_public_address))) + } + + @Test + fun `advanced mode creates closed lazy technical block and clears it on new scan`() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + invokePrivate( + activity, + "applyScanEvent", + ScanEvent.Completed(checkResult(snapshot(runTruncated = true)), false, ResultDisplayMode.ADVANCED), + false, + ) + invokePrivate(activity, "expandCategory", "dir") + val body = activity.findViewById(R.id.bodyDirect) + val toggle = findMaterialButton(body, activity.getString(R.string.technical_data_title)) + assertNotNull(toggle) + assertFalse(collectVisibleText(body).contains("current-run payload")) + + toggle!!.performClick() + assertTrue(collectVisibleText(body).contains(activity.getString(R.string.technical_data_run_truncated))) + val raw = findTextView(body) { it.text.toString().contains("current-run payload") } + assertNotNull(raw) + assertTrue(raw!!.isTextSelectable) + assertEquals(View.TEXT_DIRECTION_LTR, raw.textDirection) + assertEquals(View.LAYOUT_DIRECTION_LTR, raw.layoutDirection) + + invokePrivate(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.NORMAL) + assertFalse(collectAllText(body).contains(activity.getString(R.string.technical_data_title))) + } + + @Test + @Config(qualifiers = "fa-rIR") + fun `persian layout keeps raw diagnostic text ltr`() { + val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + invokePrivate( + activity, + "applyScanEvent", + ScanEvent.Completed(checkResult(snapshot()), false, ResultDisplayMode.ADVANCED), + false, + ) + val body = activity.findViewById(R.id.bodyDirect) + findMaterialButton(body, activity.getString(R.string.technical_data_title))!!.performClick() + val raw = findTextView(body) { it.text.toString().contains("current-run payload") }!! + + assertEquals(View.TEXT_DIRECTION_LTR, raw.textDirection) + assertEquals(View.LAYOUT_DIRECTION_LTR, raw.layoutDirection) + } + + @Test + fun `rotation replay keeps captured mode after preference changes`() { + CheckViewModel.runScanOverride = { _, _, executionContext, _ -> + executionContext.diagnosticCollector?.record( + category = "dir", + source = "fake", + status = "ok", + body = "rotation payload", + ) + checkResult() + } + val controller = Robolectric.buildActivity(MainActivity::class.java).setup() + val first = controller.get() + getPrivateField(first, "viewModel").startScan( + CheckSettings(), + privacyMode = false, + resultDisplayMode = ResultDisplayMode.ADVANCED, + ) + shadowOf(Looper.getMainLooper()).idle() + AppUiSettings.prefs(first).edit() + .putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, ResultDisplayMode.SIMPLE.prefValue) + .commit() + + controller.recreate() + shadowOf(Looper.getMainLooper()).idle() + val recreated = controller.get() + + assertNotNull( + findMaterialButton( + recreated.findViewById(R.id.bodyDirect), + recreated.getString(R.string.technical_data_title), + ), + ) + } + + private fun technicalCategory() = CategoryResult( + name = "direct", + detected = false, + findings = listOf(Finding(TECHNICAL_MARKER)), + ) + + private fun snapshot(runTruncated: Boolean = false) = DiagnosticSnapshot( + entries = listOf( + DiagnosticEntry( + category = "dir", + source = "fake probe", + target = "example.test", + status = "ok", + durationMs = 12, + body = "current-run payload", + storedBytes = 19, + originalBytes = 19, + truncated = false, + ), + ), + storedBytes = 19, + truncated = runTruncated, + ) + + private fun checkResult(snapshot: DiagnosticSnapshot? = null): CheckResult = CheckResult( + geoIp = category("geo"), + ipComparison = IpComparisonResult( + detected = false, + summary = "", + ruGroup = group("ru"), + nonRuGroup = group("non-ru"), + ), + directSigns = category("direct"), + indirectSigns = category("indirect"), + locationSignals = category("location"), + bypassResult = BypassResult( + proxyEndpoint = null, + directIp = null, + proxyIp = null, + xrayApiScanResult = null, + findings = emptyList(), + detected = false, + ), + verdict = Verdict.NOT_DETECTED, + diagnosticSnapshot = snapshot, + ) + + private fun category(name: String) = CategoryResult(name, detected = false, findings = emptyList()) + + private fun group(name: String) = IpCheckerGroupResult( + title = name, + detected = false, + statusLabel = "", + summary = "", + responses = emptyList(), + ) + + private fun ipResponse(ip: String, scope: IpCheckerScope) = IpCheckerResponse( + label = "service", + url = "https://example.test/ip", + scope = scope, + ip = ip, + ) + + private fun collectVisibleText(view: View): String { + if (view.visibility != View.VISIBLE) return "" + if (view is TextView) return view.text.toString() + if (view !is ViewGroup) return "" + return (0 until view.childCount).joinToString("\n") { collectVisibleText(view.getChildAt(it)) } + } + + private fun collectAllText(view: View): String { + if (view is TextView) return view.text.toString() + if (view !is ViewGroup) return "" + return (0 until view.childCount).joinToString("\n") { collectAllText(view.getChildAt(it)) } + } + + private fun findMaterialButton(root: View, text: String): MaterialButton? { + if (root is MaterialButton && root.text.toString() == text) return root + if (root !is ViewGroup) return null + return (0 until root.childCount).firstNotNullOfOrNull { findMaterialButton(root.getChildAt(it), text) } + } + + private fun findTextView(root: View, predicate: (TextView) -> Boolean): TextView? { + if (root is TextView && predicate(root)) return root + if (root !is ViewGroup) return null + return (0 until root.childCount).firstNotNullOfOrNull { findTextView(root.getChildAt(it), predicate) } + } + + @Suppress("UNCHECKED_CAST") + private fun getPrivateField(target: Any, name: String): T { + val field = target::class.java.getDeclaredField(name) + field.isAccessible = true + return field.get(target) as T + } + + @Suppress("UNCHECKED_CAST") + private fun invokePrivate(target: Any, name: String, vararg args: Any?): T { + val method = target::class.java.declaredMethods.first { + it.name == name && it.parameterTypes.size == args.size + } + method.isAccessible = true + return method.invoke(target, *args) as T + } + + companion object { + private const val TECHNICAL_MARKER = "enum=ROUTING command=dumpsys path=/proc/net/route" + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/SettingsActivityTest.kt b/app/src/test/java/com/notcvnt/rknhardering/SettingsActivityTest.kt index c4c9194..121e15c 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/SettingsActivityTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/SettingsActivityTest.kt @@ -61,6 +61,7 @@ class SettingsActivityTest { putBoolean(SettingsPrefs.PREF_PRIVACY_MODE, true) putString(SettingsPrefs.PREF_THEME, "system") putString(SettingsPrefs.PREF_LANGUAGE, "ru") + putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, ResultDisplayMode.ADVANCED.prefValue) putString(SettingsPrefs.PREF_COLOR_VISION_MODE, ColorVisionMode.BLUE_YELLOW.prefValue) } @@ -90,6 +91,7 @@ class SettingsActivityTest { R.string.settings_value_appearance_format, activity.getString(R.string.settings_theme_system), "RU", + activity.getString(R.string.settings_result_display_mode_advanced), ), rowValue(root, R.id.rowAppearance), ) @@ -100,6 +102,37 @@ class SettingsActivityTest { assertEquals("v${BuildConfig.VERSION_NAME}", rowValue(root, R.id.rowAbout)) } + @Test + fun `appearance fragment defaults unknown result mode to normal and saves every mode`() { + AppUiSettings.prefs(context).edit { + putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, "unexpected") + } + val activity = Robolectric.buildActivity(SettingsActivity::class.java).setup().get() + activity.supportFragmentManager.beginTransaction() + .replace(R.id.settingsFragmentContainer, SettingsAppearanceFragment()) + .commitNow() + val root = activity.supportFragmentManager + .findFragmentById(R.id.settingsFragmentContainer)!! + .requireView() + val prefs = AppUiSettings.prefs(activity) + val normal = root.findViewById(R.id.chipResultDisplayNormal) + val simple = root.findViewById(R.id.chipResultDisplaySimple) + val advanced = root.findViewById(R.id.chipResultDisplayAdvanced) + + assertTrue(normal.isChecked) + + simple.performClick() + assertEquals(ResultDisplayMode.SIMPLE.prefValue, prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null)) + normal.performClick() + assertEquals(ResultDisplayMode.NORMAL.prefValue, prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null)) + advanced.performClick() + assertEquals(ResultDisplayMode.ADVANCED.prefValue, prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null)) + assertEquals( + activity.getString(R.string.settings_result_display_mode_advanced_desc), + root.findViewById(R.id.textResultDisplayModeDescription).text.toString(), + ) + } + @Test fun `accessibility fragment saves color vision mode`() { val activity = Robolectric.buildActivity(SettingsActivity::class.java).setup().get() diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/AdvancedDiagnosticRunnerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/AdvancedDiagnosticRunnerTest.kt new file mode 100644 index 0000000..6a0f8bf --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/AdvancedDiagnosticRunnerTest.kt @@ -0,0 +1,63 @@ +package com.notcvnt.rknhardering.checker + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.customcheck.DirectSignsConfig +import com.notcvnt.rknhardering.customcheck.GeoIpConfig +import com.notcvnt.rknhardering.customcheck.IndirectSignsConfig +import com.notcvnt.rknhardering.customcheck.IpComparisonConfig +import com.notcvnt.rknhardering.customcheck.LocationSignalsConfig +import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.Finding +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AdvancedDiagnosticRunnerTest { + @After + fun tearDown() { + VpnCheckRunner.dependenciesOverride = null + } + + @Test + fun `runner snapshot contains native result returned by current invocation`() = runBlocking { + val marker = "JNI current-run getifaddrs result" + VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies( + tunInterfaceInfoCollector = { TunInterfaceInfo(false, null, null) }, + nativeCheck = { + CategoryResult( + name = "Native", + detected = false, + findings = listOf(Finding(marker, isInformational = true)), + ) + }, + ) + val collector = DiagnosticTraceCollector(privacyMode = false) + val context: Context = ApplicationProvider.getApplicationContext() + + val result = VpnCheckRunner.run( + context = context, + settings = CheckSettings( + splitTunnelEnabled = false, + networkRequestsEnabled = false, + nativeSignsEnabled = true, + icmpSpoofingEnabled = false, + geoIp = GeoIpConfig(enabled = false), + ipComparison = IpComparisonConfig(enabled = false), + directSigns = DirectSignsConfig(enabled = false), + indirectSigns = IndirectSignsConfig(enabled = false), + locationSignals = LocationSignalsConfig(enabled = false), + ), + executionContext = ScanExecutionContext(diagnosticCollector = collector), + ) + + val nativeEntry = result.diagnosticSnapshot!!.entries.single { it.source == "NativeSignsChecker" } + assertTrue(nativeEntry.body.contains(marker)) + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/IcmpSpoofingCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/IcmpSpoofingCheckerTest.kt index 16b13f9..789d22a 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/IcmpSpoofingCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/IcmpSpoofingCheckerTest.kt @@ -14,6 +14,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.io.IOException +import java.net.Inet4Address +import java.net.InetAddress import kotlin.system.measureTimeMillis @RunWith(RobolectricTestRunner::class) @@ -78,6 +80,51 @@ class IcmpSpoofingCheckerTest { assertTrue(result.hasError) } + @Test + fun `dns sinkhole address is not pinged or reported as spoofing`() = runBlocking { + var sinkholePingCalled = false + IcmpSpoofingChecker.dependenciesOverride = IcmpSpoofingChecker.Dependencies( + resolveIpv4 = { host, _ -> if (host == "instagram.com") "0.0.0.0" else "8.8.8.8" }, + ping = { address, _, _ -> + if (address == "0.0.0.0") sinkholePingCalled = true + pingResult(received = 3, min = 0.1, avg = 0.2, max = 0.3) + }, + ) + + val result = IcmpSpoofingChecker.check(context, DnsResolverConfig.system()) + + assertFalse(sinkholePingCalled) + assertFalse(result.needsReview) + assertFalse(result.evidence.any { it.source == EvidenceSource.ICMP_SPOOFING && it.detected }) + assertTrue(result.hasError) + } + + @Test + fun `only globally usable unicast IPv4 addresses may be pinged`() { + val rejected = listOf( + "0.0.0.0", + "0.0.0.1", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.0.1", + "172.16.0.1", + "192.0.0.1", + "192.0.2.1", + "192.168.0.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "255.255.255.255", + ) + + rejected.forEach { address -> + assertFalse(address, IcmpSpoofingChecker.isUsablePublicIpv4(ipv4(address))) + } + assertTrue(IcmpSpoofingChecker.isUsablePublicIpv4(ipv4("8.8.8.8"))) + } + @Test fun `icmp targets are probed in parallel`() { IcmpSpoofingChecker.dependenciesOverride = IcmpSpoofingChecker.Dependencies( @@ -148,4 +195,8 @@ class IcmpSpoofingCheckerTest { rawOutput = "", ) } + + private fun ipv4(address: String): Inet4Address { + return InetAddress.getByName(address) as Inet4Address + } } diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt index e8f6b24..d315d7f 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/NativeSignsCheckerTest.kt @@ -224,6 +224,79 @@ class NativeSignsCheckerTest { assertFalse(outcome.detected) } + @Test + fun `kernel local route for interface own address does not emit evidence`() { + // Reproduces issue #78: ccmni1 cellular interface with a public-range address + // (12.233.114.164/8) produces a kernel-managed /32 entry in the local table. + // dst == prefsrc, type=local, scope=host — this is the interface's own address, + // not a VPN server host-route leak. + val localOwnAddress = NativeRouteEntry( + interfaceName = "ccmni1", destinationHex = "0CE972A4", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "12.233.114.164", prefSrc = "12.233.114.164", + scope = "host", type = "local", table = 255, prefixLen = 32, + ) + val broadcastEntry = NativeRouteEntry( + interfaceName = "ccmni1", destinationHex = "0CFFFFFF", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "12.255.255.255", scope = "link", + type = "broadcast", table = 255, prefixLen = 32, + ) + val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(localOwnAddress, broadcastEntry)) + assertFalse(outcome.detected) + } + + @Test + fun `carrier service host routes do not emit VPN server leak evidence`() { + val routes = listOf( + NativeRouteEntry( + interfaceName = "ccmni0", destinationHex = "1A1272F5", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "26.18.114.245", scope = "link", + type = "unicast", table = 1008, prefixLen = 32, protocol = 2, + ), + NativeRouteEntry( + interfaceName = "rmnet_data0", destinationHex = "1A1272F5", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "26.18.114.245", scope = "link", + type = "unicast", table = 1009, prefixLen = 32, protocol = 2, + ), + ) + + val outcome = NativeSignsChecker.evaluateHostRoutes(context, routes) + + assertFalse(outcome.detected) + assertFalse(outcome.evidence.any { it.source == EvidenceSource.NATIVE_ROUTE }) + } + + @Test + fun `malformed local table route does not emit VPN server leak evidence`() { + val route = NativeRouteEntry( + interfaceName = "ccmni0", destinationHex = "1A1272F5", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "26.18.114.245", scope = "link", + type = "unicast", table = 255, prefixLen = 32, + ) + + val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(route)) + + assertFalse(outcome.detected) + } + + @Test + fun `static public host route via cellular interface remains detectable`() { + val route = NativeRouteEntry( + interfaceName = "ccmni0", destinationHex = "1A1272F5", gatewayHex = "00000000", + flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK, + family = 2, destination = "26.18.114.245", scope = "global", + type = "unicast", table = 1008, prefixLen = 32, protocol = 4, + ) + + val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(route)) + + assertTrue(outcome.detected) + } + @Test fun `interface with tuntap type and nonstandard name emits NATIVE_INTERFACE evidence`() { val ifaces = listOf( diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt index c706299..6f760c1 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt @@ -16,11 +16,81 @@ import com.notcvnt.rknhardering.model.GeoIpFacts import com.notcvnt.rknhardering.model.IpConsensusResult import com.notcvnt.rknhardering.model.LocationSignalsFacts import com.notcvnt.rknhardering.model.Verdict +import com.notcvnt.rknhardering.model.VerdictRuleCode import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class VerdictEngineTest { + @Test + fun `detailed R1 keeps legacy verdict and reports sources`() { + val argsBypass = bypass( + evidence = listOf(evidence(EvidenceSource.PROXY_AUTH_BYPASS, EvidenceConfidence.HIGH)), + ) + val detailed = VerdictEngine.evaluateDetailed( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = argsBypass, + ipConsensus = IpConsensusResult.empty(), + ) + + assertEquals( + VerdictEngine.evaluate( + category(), category(), category(), category(), argsBypass, IpConsensusResult.empty(), + ), + detailed.verdict, + ) + assertEquals(VerdictRuleCode.R1_HARD_BYPASS, detailed.rule) + assertEquals(setOf(EvidenceSource.PROXY_AUTH_BYPASS), detailed.participants.single().evidenceSources) + } + + @Test + fun `detailed R3 and R6 identify the deciding rule`() { + val r3 = VerdictEngine.evaluateDetailed( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult(crossChannelMismatch = true, geoCountryMismatch = true), + ) + val r6 = VerdictEngine.evaluateDetailed( + geoIp = category(), + directSigns = category(needsReview = true), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + + assertEquals(VerdictRuleCode.R3_CROSS_CHANNEL_MISMATCH, r3.rule) + assertEquals(Verdict.DETECTED, r3.verdict) + assertEquals(VerdictRuleCode.R6_FALLBACK, r6.rule) + assertEquals(Verdict.NEEDS_REVIEW, r6.verdict) + } + + @Test + fun `detailed roaming suppression remains R5 not detected`() { + val detailed = VerdictEngine.evaluateDetailed( + geoIp = category(geoFacts = GeoIpFacts(outsideRu = true, expectedRoamingExit = true)), + directSigns = category(), + indirectSigns = category(), + locationSignals = category( + findings = listOf(Finding("network_mcc_ru:true")), + locationFacts = LocationSignalsFacts(homeRoutedRoaming = true), + ), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + + assertEquals(Verdict.NOT_DETECTED, detailed.verdict) + assertEquals(VerdictRuleCode.R5_MATRIX, detailed.rule) + assertTrue(detailed.participants.any { it.factor == "expected_roaming_exit=true" }) + } + @Test fun `R1 split tunnel evidence yields detected`() { val verdict = VerdictEngine.evaluate( @@ -588,6 +658,57 @@ class VerdictEngineTest { assertEquals(Verdict.NEEDS_REVIEW, verdict) } + @Test + fun `high confidence native socket evidence confirms foreign geo`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(geoFacts = GeoIpFacts(outsideRu = true, countryCode = "DE")), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + nativeSigns = category( + evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.HIGH)), + ), + ) + + assertEquals(Verdict.DETECTED, verdict) + } + + @Test + fun `high confidence native socket evidence alone yields needs review`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + nativeSigns = category( + evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.HIGH)), + ), + ) + + assertEquals(Verdict.NEEDS_REVIEW, verdict) + } + + @Test + fun `medium confidence native socket evidence does not enter verdict matrix`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + nativeSigns = category( + evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.MEDIUM)), + ), + ) + + assertEquals(Verdict.NOT_DETECTED, verdict) + } + @Test fun `proxy assisted udp leak does not affect verdict`() { val verdict = VerdictEngine.evaluate( diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt new file mode 100644 index 0000000..223077d --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VpnNativeDetectorCheckerTest.kt @@ -0,0 +1,110 @@ +package com.notcvnt.rknhardering.checker + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.notcvnt.rknhardering.ScanCancellationSignal +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceSource +import com.notcvnt.rknhardering.probe.NativeSignsBridge +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VpnNativeDetectorCheckerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + NativeSignsBridge.resetForTests() + NativeSignsBridge.isLibraryLoadedOverride = { true } + } + + @After + fun tearDown() { + NativeSignsBridge.resetForTests() + } + + @Test + fun `neutral measurements are informational and do not create evidence`() { + NativeSignsBridge.detectVpnDetectorOverride = { + arrayOf( + "vdet|route_count|routes=12", + "vdet|pmtu_mss_combined|tcp_snd_mss=1440 tcp_rcv_mss=1440", + "vdet|udp_pmtu_ok|sent=1500 bytes", + "vdet|normal_pmtu|iface=wlan0 mtu=1500", + "vdet|timing_oracle|min=1 max=2 avg=1", + "vdet|backpressure|50000/50000 pkts sent", + "vdet|gso_ok|supported", + "vdet|hw_timestamp|unsupported", + ) + } + + val result = runBlocking { VpnNativeDetectorChecker.check(context) } + + assertFalse(result.detected) + assertFalse(result.needsReview) + assertTrue(result.evidence.isEmpty()) + assertTrue(result.findings.all { it.isInformational }) + } + + @Test + fun `gso failures are capability diagnostics instead of vpn evidence`() { + NativeSignsBridge.detectVpnDetectorOverride = { + arrayOf( + "vdet|gso_failed|errno=92", + "vdet|gso_send_failed|errno=90", + ) + } + + val result = runBlocking { VpnNativeDetectorChecker.check(context) } + + assertFalse(result.detected) + assertFalse(result.needsReview) + assertTrue(result.evidence.isEmpty()) + assertTrue(result.findings.all { it.isInformational }) + } + + @Test + fun `high confidence socket leak remains detected evidence`() { + NativeSignsBridge.detectVpnDetectorOverride = { + arrayOf("vdet|bindtodevice_leak|setsockopt(tun0) succeeded") + } + + val result = runBlocking { VpnNativeDetectorChecker.check(context) } + val evidence = result.evidence.single() + + assertTrue(result.detected) + assertEquals(EvidenceSource.NATIVE_SOCKET, evidence.source) + assertEquals(EvidenceConfidence.HIGH, evidence.confidence) + assertTrue(evidence.detected) + } + + @Test + fun `native detector receives scan cancellation and aborts result`() { + val cancellationSignal = ScanCancellationSignal() + val executionContext = ScanExecutionContext(cancellationSignal = cancellationSignal) + NativeSignsBridge.detectVpnDetectorOverride = { receivedSignal -> + assertSame(cancellationSignal, receivedSignal) + cancellationSignal.cancel() + arrayOf("vdet|backpressure|partial") + } + + assertThrows(CancellationException::class.java) { + runBlocking(executionContext.asCoroutineContext()) { + VpnNativeDetectorChecker.check(context) + } + } + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/diagnostics/DiagnosticCaptureIntegrationTest.kt b/app/src/test/java/com/notcvnt/rknhardering/diagnostics/DiagnosticCaptureIntegrationTest.kt new file mode 100644 index 0000000..9b8bb23 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/diagnostics/DiagnosticCaptureIntegrationTest.kt @@ -0,0 +1,76 @@ +package com.notcvnt.rknhardering.diagnostics + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.network.DnsResolverConfig +import com.notcvnt.rknhardering.network.ResolverHttpResponse +import com.notcvnt.rknhardering.network.ResolverNetworkStack +import com.notcvnt.rknhardering.probe.SystemPingProber +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DiagnosticCaptureIntegrationTest { + @After + fun tearDown() { + ResolverNetworkStack.resetForTests() + SystemPingProber.runCommandOverride = null + } + + @Test + fun `http captures actual status duration and used response body`() = runBlocking { + val collector = DiagnosticTraceCollector(privacyMode = false) + val executionContext = ScanExecutionContext(diagnosticCollector = collector) + ResolverNetworkStack.okHttpExecuteOverride = { + ResolverHttpResponse(207, "body-used token=do-not-store") + } + + val response = kotlinx.coroutines.withContext(executionContext.asCoroutineContext()) { + ResolverNetworkStack.execute( + url = "https://example.test/value", + method = "GET", + timeoutMs = 1_000, + config = DnsResolverConfig.system(), + cancellationSignal = executionContext.cancellationSignal, + ) + } + + assertEquals(207, response.code) + val entry = collector.snapshot().entries.single() + assertEquals("HTTP 207", entry.status) + assertTrue(entry.durationMs != null) + assertTrue(entry.body.contains("body-used")) + assertFalse(entry.body.contains("do-not-store")) + } + + @Test + fun `ping captures exact command exit stdout and stderr from current run`() = runBlocking { + val collector = DiagnosticTraceCollector(privacyMode = false) + val executionContext = ScanExecutionContext(diagnosticCollector = collector) + SystemPingProber.runCommandOverride = { + SystemPingProber.CommandResult( + exitCode = 0, + output = "3 packets transmitted, 2 received\nrtt min/avg/max = 10/20/30 ms", + stderr = "diagnostic stderr", + ) + } + + val result = kotlinx.coroutines.withContext(executionContext.asCoroutineContext()) { + SystemPingProber.probe("203.0.113.5", executionContext = executionContext) + } + + assertEquals(2, result.received) + val entry = collector.snapshot().entries.single() + assertTrue(entry.body.contains("command=ping -4 -n -c 3 -W 4 203.0.113.5")) + assertTrue(entry.body.contains("exitCode=0")) + assertTrue(entry.body.contains("3 packets transmitted")) + assertTrue(entry.body.contains("diagnostic stderr")) + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/diagnostics/DiagnosticTraceCollectorTest.kt b/app/src/test/java/com/notcvnt/rknhardering/diagnostics/DiagnosticTraceCollectorTest.kt new file mode 100644 index 0000000..bae01ae --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/diagnostics/DiagnosticTraceCollectorTest.kt @@ -0,0 +1,140 @@ +package com.notcvnt.rknhardering.diagnostics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class DiagnosticTraceCollectorTest { + @Test + fun `records concurrently and sorts deterministically`() { + val collector = DiagnosticTraceCollector(privacyMode = false) + val pool = Executors.newFixedThreadPool(4) + val start = CountDownLatch(1) + val done = CountDownLatch(40) + + repeat(40) { index -> + pool.execute { + start.await() + collector.record( + category = if (index % 2 == 0) "b" else "a", + source = "source-${40 - index}", + status = "ok", + body = "body-$index", + ) + done.countDown() + } + } + start.countDown() + assertTrue(done.await(2, TimeUnit.SECONDS)) + pool.shutdownNow() + + val snapshot = collector.snapshot() + assertEquals(40, snapshot.entries.size) + assertEquals( + snapshot.entries.sortedWith( + compareBy( + DiagnosticEntry::category, + DiagnosticEntry::source, + { it.target.orEmpty() }, + DiagnosticEntry::status, + DiagnosticEntry::body, + ), + ), + snapshot.entries, + ) + } + + @Test + fun `enforces utf8 entry and run limits`() { + val collector = DiagnosticTraceCollector( + privacyMode = false, + entryLimitBytes = 16, + runLimitBytes = 22, + ) + collector.record("a", "one", status = "ok", body = "ёжикёж") + collector.record("b", "two", status = "ok", body = "abcdef") + + val snapshot = collector.snapshot() + assertEquals(22, snapshot.storedBytes) + assertTrue(snapshot.truncated) + assertEquals(18, snapshot.entries.first { it.source == "one" }.originalBytes) + assertTrue(snapshot.entries.all { it.storedBytes <= 16 }) + assertTrue(snapshot.entries.all { it.truncated }) + } + + @Test + fun `redacts secrets before storage`() { + val collector = DiagnosticTraceCollector(privacyMode = false) + collector.record( + category = "http", + source = "client", + target = "https://alice:password@example.test/path?token=secret-token&safe=yes", + status = "Authorization: Bearer abc.def", + body = """ + Cookie: session=hidden + password=hunter2 + {"cookie":"SID=body-secret","authorization":"Token body-auth","key":"body-key"} + uuid=123e4567-e89b-12d3-a456-426614174000 + bssid=aa:bb:cc:dd:ee:ff + """.trimIndent(), + ) + + val entry = collector.snapshot().entries.single() + val stored = listOf(entry.target, entry.status, entry.body).joinToString("\n") + listOf( + "alice", "password@example", "secret-token", "abc.def", "hidden", "hunter2", + "body-secret", "body-auth", "body-key", "123e4567", "aa:bb", + ).forEach { + assertFalse("Leaked <$it> in <$stored>", stored.contains(it, ignoreCase = true)) + } + assertTrue(stored.contains("[REDACTED]")) + } + + @Test + fun `privacy mode masks public and local ip addresses in every field`() { + val collector = DiagnosticTraceCollector(privacyMode = true) + collector.record( + category = "10.0.0.1", + source = "2001:db8::1", + target = "127.0.0.1", + status = "from 192.0.2.10", + body = "local=::1 public=8.8.8.8", + ) + + val entry = collector.snapshot().entries.single() + val stored = listOf(entry.category, entry.source, entry.target, entry.status, entry.body).joinToString("\n") + listOf("10.0.0.1", "2001:db8::1", "127.0.0.1", "192.0.2.10", "::1", "8.8.8.8").forEach { + assertFalse(stored.contains(it)) + } + assertTrue(stored.contains("redacted")) + } + + @Test + fun `clear and snapshot reject later writes`() { + val collector = DiagnosticTraceCollector(privacyMode = false) + collector.record("a", "before", status = "ok", body = "kept") + collector.clear() + collector.record("b", "after", status = "ok", body = "ignored") + assertEquals(DiagnosticSnapshot.EMPTY, collector.snapshot()) + } + + @Test + fun `scan truncation remains visible when a later entry cannot fit`() { + val collector = DiagnosticTraceCollector( + privacyMode = false, + entryLimitBytes = 8, + runLimitBytes = 8, + ) + collector.record("a", "one", status = "ok", body = "ab") + collector.record("b", "two", status = "ok", body = "c") + + val snapshot = collector.snapshot() + assertEquals(1, snapshot.entries.size) + assertFalse(snapshot.entries.single().truncated) + assertTrue(snapshot.truncated) + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/network/NetworkInterfacePatternsTest.kt b/app/src/test/java/com/notcvnt/rknhardering/network/NetworkInterfacePatternsTest.kt index 8dc1da3..119377f 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/network/NetworkInterfacePatternsTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/network/NetworkInterfacePatternsTest.kt @@ -30,4 +30,14 @@ class NetworkInterfacePatternsTest { assertTrue(NetworkInterfacePatterns.isIpsecInterface("xfrm0")) assertFalse(NetworkInterfacePatterns.isVpnInterface("xfrm0")) } + + @Test + fun `classifies cellular modem interfaces`() { + val modemNames = listOf("rmnet_data0", "ccmni0", "ccemni1", "pdp0", "seth_lte0", "v4-ccmni0") + for (name in modemNames) { + assertTrue("expected $name to be cellular", NetworkInterfacePatterns.isCellularModemInterface(name)) + } + assertFalse(NetworkInterfacePatterns.isCellularModemInterface("wlan0")) + assertFalse(NetworkInterfacePatterns.isCellularModemInterface("eth0")) + } } diff --git a/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt b/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt index 5a47467..bb1d7e9 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/network/ResolverBindingTest.kt @@ -6,8 +6,11 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import java.net.DatagramSocket +import java.net.Socket class ResolverBindingTest { @@ -31,6 +34,24 @@ class ResolverBindingTest { assertEquals(listOf("tun0"), boundInterfaces) } + @Test + fun `socket binding infrastructure failure is propagated`() { + Socket().use { socket -> + assertThrows(RuntimeException::class.java) { + ResolverSocketBinder.bind(socket, ResolverBinding.OsDeviceBinding("missing0")) + } + } + } + + @Test + fun `datagram binding infrastructure failure is propagated`() { + DatagramSocket().use { socket -> + assertThrows(RuntimeException::class.java) { + ResolverSocketBinder.bind(socket, ResolverBinding.OsDeviceBinding("missing0")) + } + } + } + @Test fun `build client uses bind to device socket factory for os device binding`() { val client = ResolverNetworkStack.buildClient( diff --git a/app/src/test/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbeTest.kt b/app/src/test/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbeTest.kt index bab542f..66aa68f 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbeTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/probe/NativeInterfaceProbeTest.kt @@ -147,13 +147,28 @@ class NativeInterfaceProbeTest { @Test fun `parseNetlinkRoutes preserves prefix length for host routes`() { val rows = arrayOf( - "route|family=2|dst=203.0.113.7/32|via=192.168.1.1|dev=wlan0|oif=5", + "route|family=2|dst=203.0.113.7/32|via=192.168.1.1|dev=wlan0|oif=5|proto=2", "route|family=2|dst=10.8.0.0/24|dev=tun0|oif=7", ) val parsed = NativeInterfaceProbe.parseNetlinkRoutes(rows) val hostRoute = parsed.first { it.destination == "203.0.113.7" } assertEquals(32, hostRoute.prefixLen) + assertEquals(2, hostRoute.protocol) val subnet = parsed.first { it.destination == "10.8.0.0" } assertEquals(24, subnet.prefixLen) } + + @Test + fun `parseNetlinkRoutes recognizes native textual address families`() { + val rows = arrayOf( + "route|family=inet|dst=default/0|dev=rmnet_data0|oif=5|table=254|scope=global|type=unicast|proto=3", + "route|family=inet6|dst=default/0|dev=rmnet_data0|oif=5|table=254|scope=global|type=unicast|proto=3", + ) + + val parsed = NativeInterfaceProbe.parseNetlinkRoutes(rows) + + assertEquals(listOf(2, 10), parsed.map { it.family }) + assertEquals(listOf(8, 32), parsed.map { it.destinationHex.length }) + assertTrue(parsed.all { it.isDefault }) + } } diff --git a/app/src/test/java/com/notcvnt/rknhardering/probe/NativeSignsBridgeDiagnosticTest.kt b/app/src/test/java/com/notcvnt/rknhardering/probe/NativeSignsBridgeDiagnosticTest.kt new file mode 100644 index 0000000..b3a3732 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/probe/NativeSignsBridgeDiagnosticTest.kt @@ -0,0 +1,39 @@ +package com.notcvnt.rknhardering.probe + +import com.notcvnt.rknhardering.ScanExecutionContext +import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeSignsBridgeDiagnosticTest { + @After + fun tearDown() { + NativeSignsBridge.resetForTests() + } + + @Test + fun `captures raw jni and proc values from original invocation`() = runBlocking { + val interfaceRow = "tun0|7|65|AF_INET|203.0.113.8|255.255.255.0|1400|65534" + val procRoute = "Iface Destination Gateway Flags\ntun0 00000000 0100000A 0003" + NativeSignsBridge.isLibraryLoadedOverride = { true } + NativeSignsBridge.getIfAddrsOverride = { arrayOf(interfaceRow) } + NativeSignsBridge.netlinkRouteDumpOverride = { emptyArray() } + NativeSignsBridge.readProcFileOverride = { path, _ -> + procRoute.takeIf { path.endsWith("/route") } + } + val collector = DiagnosticTraceCollector(privacyMode = false) + val executionContext = ScanExecutionContext(diagnosticCollector = collector) + + withContext(executionContext.asCoroutineContext()) { + NativeInterfaceProbe.collectInterfaces() + NativeInterfaceProbe.collectRoutes() + } + + val snapshot = collector.snapshot() + assertTrue(snapshot.entries.single { it.source == "getifaddrs" }.body.contains(interfaceRow)) + assertTrue(snapshot.entries.any { it.source == "readProcFile" && it.body.contains(procRoute) }) + } +} diff --git a/app/src/test/java/com/notcvnt/rknhardering/ui/main/SimpleResultModelsTest.kt b/app/src/test/java/com/notcvnt/rknhardering/ui/main/SimpleResultModelsTest.kt new file mode 100644 index 0000000..fb6ee61 --- /dev/null +++ b/app/src/test/java/com/notcvnt/rknhardering/ui/main/SimpleResultModelsTest.kt @@ -0,0 +1,237 @@ +package com.notcvnt.rknhardering.ui.main + +import com.notcvnt.rknhardering.model.BypassResult +import com.notcvnt.rknhardering.model.CallTransportLeakResult +import com.notcvnt.rknhardering.model.CallTransportNetworkPath +import com.notcvnt.rknhardering.model.CallTransportProbeKind +import com.notcvnt.rknhardering.model.CallTransportService +import com.notcvnt.rknhardering.model.CallTransportStatus +import com.notcvnt.rknhardering.model.CategoryResult +import com.notcvnt.rknhardering.model.CdnPullingResult +import com.notcvnt.rknhardering.model.DomainReachabilityResponse +import com.notcvnt.rknhardering.model.DomainReachabilityResult +import com.notcvnt.rknhardering.model.DomainReachabilityStepStatus +import com.notcvnt.rknhardering.model.Finding +import com.notcvnt.rknhardering.model.EvidenceConfidence +import com.notcvnt.rknhardering.model.EvidenceItem +import com.notcvnt.rknhardering.model.EvidenceSource +import com.notcvnt.rknhardering.model.IpCheckerGroupResult +import com.notcvnt.rknhardering.model.IpComparisonResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class SimpleResultModelsTest { + @Test + fun `category covers clean review detected and error`() { + assertEquals(SimpleResultStatus.CLEAN, SimpleResultModels.category(category()).status) + assertEquals( + SimpleResultStatus.REVIEW, + SimpleResultModels.category(category(needsReview = true)).status, + ) + assertEquals( + SimpleResultStatus.DETECTED, + SimpleResultModels.category(category(detected = true)).status, + ) + assertEquals( + SimpleResultStatus.ERROR, + SimpleResultModels.category(category(findings = listOf(Finding("failure", isError = true)))).status, + ) + } + + @Test + fun `specialized models map structured states without parsing text`() { + val emptyGroup = IpCheckerGroupResult( + title = "group", + detected = false, + statusLabel = "", + summary = "", + responses = emptyList(), + ) + assertEquals( + SimpleResultStatus.REVIEW, + SimpleResultModels.ipComparison( + IpComparisonResult( + detected = false, + needsReview = true, + summary = "arbitrary technical text", + ruGroup = emptyGroup, + nonRuGroup = emptyGroup, + ), + ).status, + ) + assertEquals( + SimpleResultStatus.DETECTED, + SimpleResultModels.cdn(CdnPullingResult(detected = true, summary = "raw enum UNKNOWN")).status, + ) + assertEquals( + SimpleResultStatus.REVIEW, + SimpleResultModels.bypass(bypass(needsReview = true)).status, + ) + assertEquals( + SimpleResultStatus.REVIEW, + SimpleResultModels.callTransport( + leaks = listOf( + CallTransportLeakResult( + service = CallTransportService.TELEGRAM, + probeKind = CallTransportProbeKind.DIRECT_UDP_STUN, + networkPath = CallTransportNetworkPath.ACTIVE, + status = CallTransportStatus.NEEDS_REVIEW, + summary = "ignored", + ), + ), + stunGroups = emptyList(), + ).status, + ) + } + + @Test + fun `ip comparison reports the exact disagreeing group from structured state`() { + val ruGroup = IpCheckerGroupResult( + title = "ru", + detected = true, + statusLabel = "ignored", + summary = "raw summary must not be parsed", + responses = emptyList(), + ) + val otherGroup = IpCheckerGroupResult( + title = "other", + detected = false, + statusLabel = "ignored", + summary = "ignored", + canonicalIp = "203.0.113.1", + responses = emptyList(), + ) + + val model = SimpleResultModels.ipComparison( + IpComparisonResult( + detected = false, + needsReview = true, + summary = "different wording", + ruGroup = ruGroup, + nonRuGroup = otherGroup, + ), + ) + + assertEquals(listOf(SimpleResultCause.IP_RU_SERVICES_DISAGREE), model.causes) + } + + @Test + fun `category preserves distinct structured causes in deterministic order`() { + val result = CategoryResult( + name = "direct", + detected = true, + findings = listOf( + Finding( + description = "secret path and raw error must be ignored", + detected = true, + source = EvidenceSource.ROUTING, + ), + Finding( + description = "another technical sentence", + detected = true, + source = EvidenceSource.SYSTEM_PROXY, + ), + Finding( + description = "duplicate", + detected = true, + source = EvidenceSource.ROUTING, + ), + ), + ) + + assertEquals( + listOf(SimpleResultCause.SYSTEM_PROXY, SimpleResultCause.VPN_ROUTE), + SimpleResultModels.category(result).causes, + ) + } + + @Test + fun `error cause describes unavailable data without claiming a detected signal`() { + val result = category( + findings = listOf( + Finding( + description = "raw provider error must not be parsed", + isError = true, + source = EvidenceSource.GEO_IP, + ), + ), + ) + + assertEquals( + listOf(SimpleResultCause.PUBLIC_DATA_UNAVAILABLE), + SimpleResultModels.category(result).causes, + ) + } + + @Test + fun `domain reachability distinguishes empty clean and mismatch`() { + assertEquals( + SimpleResultStatus.ERROR, + SimpleResultModels.domainReachability(DomainReachabilityResult.empty()).status, + ) + assertEquals( + SimpleResultStatus.CLEAN, + SimpleResultModels.domainReachability(DomainReachabilityResult(listOf(domain(matches = true)))).status, + ) + val mismatch = SimpleResultModels.domainReachability( + DomainReachabilityResult(listOf(domain(matches = false))), + ) + assertEquals(SimpleResultStatus.DETECTED, mismatch.status) + assertEquals(listOf(SimpleResultCause.DOMAIN_TCP_MISMATCH), mismatch.causes) + } + + @Test + fun `disabled remains an explicit presentation state`() { + assertEquals(SimpleResultStatus.DISABLED, SimpleCardModel(SimpleResultStatus.DISABLED).status) + } + + @Test + fun `installed app signal stays clean but is marked as additional information`() { + val result = CategoryResult( + name = "direct", + detected = false, + findings = emptyList(), + evidence = listOf( + EvidenceItem( + source = EvidenceSource.INSTALLED_APP, + detected = false, + confidence = EvidenceConfidence.LOW, + description = "diagnostic only", + ), + ), + ) + + val model = SimpleResultModels.category(result) + assertEquals(SimpleResultStatus.CLEAN, model.status) + assertEquals(true, model.extraInformation) + } + + private fun category( + detected: Boolean = false, + needsReview: Boolean = false, + findings: List = emptyList(), + ) = CategoryResult( + name = "test", + detected = detected, + needsReview = needsReview, + findings = findings, + ) + + private fun bypass(needsReview: Boolean) = BypassResult( + proxyEndpoint = null, + directIp = null, + proxyIp = null, + xrayApiScanResult = null, + findings = emptyList(), + detected = false, + needsReview = needsReview, + ) + + private fun domain(matches: Boolean): DomainReachabilityResponse = DomainReachabilityResponse( + domain = "example.test", + label = "Example", + dnsStatus = DomainReachabilityStepStatus.OK, + tcpStatus = if (matches) DomainReachabilityStepStatus.OK else DomainReachabilityStepStatus.FAILED, + tlsStatus = DomainReachabilityStepStatus.OK, + ) +} diff --git a/docs/README.en.md b/docs/README.en.md index 6b2b7ee..7ab0c12 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -501,7 +501,7 @@ For every interface, `/sys/class/net//type` is read. A value of `65534` (` #### 12.2 Host-route /32 heuristic -The routing table (NETLINK) is scanned for a non-default route with a `/32` prefix (IPv4) or `/128` (IPv6) to a publicly routable address via a physical interface (`wlan0`, `rmnet`, `eth0`). This is the classic VPN client host-route to the server IP that bypasses the tunnel — a leak of the real VPN server IP. Result: `detected = true` (`EvidenceSource.NATIVE_ROUTE`). +The routing table (NETLINK) is scanned for a non-default route with a `/32` prefix (IPv4) or `/128` (IPv6) to a publicly routable address via a physical interface (`wlan0`, `rmnet`, `eth0`). This is the classic VPN client host-route to the server IP that bypasses the tunnel — a leak of the real VPN server IP. Entries from the `local` table and kernel-created link routes on modem interfaces (`rmnet*`, `ccmni*`, `pdp*`, `seth*`) are excluded because carriers use them for service addressing independently of a VPN. Static global host routes on these interfaces remain candidates. Result: `detected = true` (`EvidenceSource.NATIVE_ROUTE`). #### 12.3 Emulator detector @@ -519,6 +519,109 @@ Identifies contexts where another user's or profile's VPN is invisible to networ Any of these signals yields `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), never `detected`. +#### 12.5 VPN Signals (`evaluateVpnSignals`) + +Comprehensive VPN detection via native JNI calls. All checks work on **non-rooted** devices — when permissions are missing (SELinux/capabilities), the check is marked as `unavailable` and does not crash. + +**Properties and files (`nativeDetectVpnProperties`):** + +| Check | What we look for | Source | +|-------|------------------|--------| +| DNS properties | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` | +| VPN properties | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` | +| vpnhide files | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` etc. | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| Hook properties | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` | + +High confidence: `vpn_prop`, `vpnhide`, `hook_prop`. Medium: others. + +**Leaks via /proc (`nativeDetectVpnLeaks`):** + +| Check | What we look for | Source | +|-------|------------------|--------| +| TCP VPN ports | Connections on ports 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` | +| UDP VPN ports | Sockets on ports 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` | +| if_inet6 | tun/wg/ppp/tap interfaces in `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| Route VPN | Routes via tun/wg/ppp/tap | `/proc/net/route` | +| FIB trie | `/32 host` entries (non-LOCAL) — VPN host routes | `/proc/net/fib_trie` | + +High confidence: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`. + +**Advanced checks (`nativeDetectVpnAdvanced`):** + +| Check | What we look for | Source | +|-------|------------------|--------| +| ARP VPN | Entries on tun/wg/ppp interfaces | `/proc/net/arp` | +| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` etc. | +| ESTABLISHED VPN | Connections to private IPs on VPN ports | `/proc/net/tcp` | + +**Non-traditional syscall checks (`nativeDetectVpnSyscalls`):** + +Checks via direct netlink requests and probe connections. Returns `unavailable` when permissions are missing: + +| Check | Method | What it detects | +|-------|--------|-----------------| +| RTM_GETRULE | Netlink dump of routing policy rules | VPN policy routing rules | +| RTM_GETQDISC | Netlink dump of queueing disciplines | VPN qdisc tunnels | +| RTM_GETNEIGH | Netlink dump of neighbor table | Hidden MAC addresses (zero LLADDR) | +| TCP_INFO MSS | Connect to 8.8.8.8:443, read `snd_mss` | MSS reduction (tunnel indicator) | +| SO_BINDTODEVICE | Probe bind to non-existent interface | VPN hook intercepting setsockopt | +| Loopback port bind | Try to claim ports 51820/1194/443/8443 | Port conflicts (VPN listener) | +| BPF OBJ GET | Try to open `/sys/fs/bpf/` maps | Access to netd BPF maps | +| IP_RECVERR | Probe setsockopt IP_RECVERR | VPN hook intercepting IP_RECVERR | + +High confidence: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`. + +#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`) + +The new detectors live in a dedicated sub-section inside the Native category, grouped into 4 sub-categories. Data comes from the new JNI method `nativeDetectVpnDetector()`, with lines prefixed `vdet|`. + +**Direct signs — `EvidenceSource.NATIVE_INTERFACE`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `sysfs_vpn_leak` | tun/wg/ppp/xfrm leak via sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | VPN interfaces in `getifaddrs()` list | `getifaddrs()` | +| `sysclassnet_vpn` | VPN interfaces in `/sys/class/net` | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | VPN interfaces via netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | VPN interfaces in `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | VPN routes in `/proc/net/ipv6_route` | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | VPN traffic (RX/TX) in `/proc/net/dev` | `/proc/net/dev` | +| `ifindexname_vpn` | VPN interfaces via `if_indextoname()` | `if_indextoname()` ifindex sweep | +| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100–200, oif=tun) | Netlink `RTM_GETRULE` dump | + +**Network signs — `EvidenceSource.NATIVE_SOCKET`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `fib_trie_denied` | `/proc/net/fib_trie` unavailable (SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink denied (SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` to tun + `getsockopt` confirm | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` returns private VPN IP | `getsockname()` on UDP socket | +| `udp_port_conflict_physical` | UDP port conflict (500/4500/1194/1701/51820) on physical IP | `bind()` on physical IP | +| `route_count` | Route count and unique interface count | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | bind-probe vs RTM_GETLINK iface count mismatch | `if_indextoname()` vs `RTM_GETLINK` | + +**Indirect signs — `EvidenceSource.NATIVE_ROUTE`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | Success/failure sending 1500 bytes over UDP | `sendto()` 1500 bytes | +| `normal_pmtu` | Path MTU of primary physical interface | `fetchMtu()` via `getifaddrs()` | +| `timing_oracle` | ARM CNTVCT cycles for `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) | +| `backpressure` | Throughput under 50000 UDP packets with scan cancellation | Non-blocking `sendto()` burst + cancellation check every 64 packets | +| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO capability diagnostics; the result is not VPN evidence | `UDP_SEGMENT=1200` + 4800-byte send | +| `hw_timestamp` | Hardware timestamping (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**Environment probes — `EvidenceSource.NATIVE_INTERFACE`:** + +| Check (kind) | What it looks for | Source | +|--------------|-------------------|--------| +| `traceroute_denied` | Traceroute probe (TTL=1 UDP) blocked | `setsockopt(IP_TTL=1)` + `sendto()` | + +High confidence (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Raw measurements and GSO results are informational; other anomalies → `needsReview`. + --- ## Verdict (`VerdictEngine`) @@ -544,7 +647,7 @@ The `expectedRoamingExit` flag (resolved by `HomeNetworkCatalog` from SIM MCC/MN - `geoHit` = `GeoIP.outsideRu == true` (excluding roaming) - `directHit` = detected evidence from `DIRECT_NETWORK_CAPABILITIES` or `SYSTEM_PROXY` -- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` +- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`, or high-confidence `NATIVE_SOCKET` | Geo | Direct | Indirect | Verdict | |-----|--------|----------|---------| @@ -585,6 +688,12 @@ Requirements: JDK 17+, Android SDK with Build Tools for API 36. ./gradlew assembleDebug ``` +## Result detail modes + +Settings → Appearance provides three modes. Simple shows plain-language outcomes without low-level strings, Normal preserves the standard presentation, and Advanced adds collapsed technical data to each result card. The selection is captured when a scan starts, so changing it affects only the next scan and never redraws a running or completed scan. The mode does not change checks, verdicts, timeouts, or network behavior. + +The technical snapshot is created only for scans started in Advanced mode. It exists only in process memory, is cleared on cancellation or a new scan, and is not included in JSON/Markdown exports. Each entry is limited to 64 KiB and a scan to 512 KiB; truncation is shown explicitly. Authorization and cookie headers, passwords, tokens, UUIDs, keys, URI userinfo, and sensitive query parameters are removed before storage. Privacy mode additionally masks every IPv4/IPv6 address. Full Clash `/configs`, `/connections`, `/proxies` responses, Xray UUIDs/public keys, BSSIDs, and cell identifiers are never stored. + --- ## Acknowledgements diff --git a/docs/README.fa.md b/docs/README.fa.md index 15482b0..486b886 100644 --- a/docs/README.fa.md +++ b/docs/README.fa.md @@ -503,7 +503,7 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می #### 12.2 هیوریستیک host-route /32 -در جدول مسیریابی (`NETLINK`) به دنبال مسیر non-default با پیشوند `/32` (IPv4) یا `/128` (IPv6) به یک آدرس قابل مسیریابی عمومی از طریق اینترفیس فیزیکی (`wlan0`، `rmnet`، `eth0`) می‌گردد. این الگوی کلاسیک host-route کلاینت VPN به IP سرور خود در دور زدن تانل است — نشت IP واقعی سرور VPN. نتیجه: `detected = true` (`EvidenceSource.NATIVE_ROUTE`). +در جدول مسیریابی (`NETLINK`) به دنبال مسیر non-default با پیشوند `/32` (IPv4) یا `/128` (IPv6) به یک آدرس قابل مسیریابی عمومی از طریق اینترفیس فیزیکی (`wlan0`، `rmnet`، `eth0`) می‌گردد. این الگوی کلاسیک host-route کلاینت VPN به IP سرور خود در دور زدن تانل است — نشت IP واقعی سرور VPN. ورودی‌های جدول `local` و مسیرهای link ساخته‌شده توسط کرنل روی اینترفیس‌های مودم (`rmnet*`، `ccmni*`، `pdp*`، `seth*`) نادیده گرفته می‌شوند، زیرا اپراتورها مستقل از VPN از آن‌ها برای آدرس‌دهی سرویس استفاده می‌کنند. host-routeهای static و global روی این اینترفیس‌ها همچنان بررسی می‌شوند. نتیجه: `detected = true` (`EvidenceSource.NATIVE_ROUTE`). #### 12.3 تشخیصگر شبیه‌ساز @@ -521,6 +521,108 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می هر کدام از این سیگنال‌ها `needsReview = true` می‌دهند (`EvidenceSource.SANDBOX_ISOLATION`)، **هرگز** `detected`. +#### 12.5 سیگنال‌های VPN (`evaluateVpnSignals`) + +تشخیص جامع VPN از طریق فراخوانی‌های JNI بومی. تمام بررسی‌ها روی دستگاه‌های **بدون روت** کار می‌کنند — وقتی مجوزها وجود نداشته باشند (SELinux/capabilities)، بررسی به عنوان `unavailable` علامت‌گذاری می‌شود و کرش نمی‌کند. + +**ویژگی‌ها و فایل‌ها (`nativeDetectVpnProperties`):** + +| بررسی | چه چیزی جستجو می‌شود | منبع | +|-------|---------------------|------| +| ویژگی‌های DNS | `net.dns1-4`، `net.vpn.dns1-2`، `dhcp.tun0.dns1-2` | `__system_property_get` | +| ویژگی‌های VPN | `net.vpn.default_iface`، `vpn.enable`، `net.tun0.dns1-2`، `net.ppp0.dns1-2` | `__system_property_get` | +| فایل‌های vpnhide | `/data/local/vpnhide`، `/data/adb/vpnhide`، `/data/local/bypass` و غیره | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`، `/data/adb/modules/lsposed`، `/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| ویژگی‌های Hook | `persist.sys.lspd`، `persist.sys.lsposed`، `ro.lsposed.hidden` | `__system_property_get` | + +اطمینان بالا: `vpn_prop`، `vpnhide`، `hook_prop`. متوسط: بقیه. + +**نشت از طریق /proc (`nativeDetectVpnLeaks`):** + +| بررسی | چه چیزی جستجو می‌شود | منبع | +|-------|---------------------|------| +| پورت‌های TCP VPN | اتصالات روی پورت‌های 443، 1194، 51820، 8443، 1723، 500، 4500 | `/proc/net/tcp[6]` | +| پورت‌های UDP VPN | سوکت‌های روی پورت‌های 51820 (WireGuard)، 1194 (OpenVPN)، 500، 4500 | `/proc/net/udp[6]` | +| if_inet6 | رابط‌های tun/wg/ppp/tap در `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| مسیر VPN | مسیرهای از طریق tun/wg/ppp/tap | `/proc/net/route` | +| FIB trie | رکوردهای `/32 host` (غیر LOCAL) — مسیرهای میزبان VPN | `/proc/net/fib_trie` | + +اطمینان بالا: `udp_vpn_port`، `route_vpn_iface`، `arp_vpn_iface`، `inet6_vpn_iface`. + +**بررسی‌های پیشرفته (`nativeDetectVpnAdvanced`):** + +| بررسی | چه چیزی جستجو می‌شود | منبع | +|-------|---------------------|------| +| ARP VPN | رکوردهای روی رابط‌های tun/wg/ppp | `/proc/net/arp` | +| Sysctl | `rp_filter=0`، `ip_forward=1`، `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` و غیره | +| ESTABLISHED VPN | اتصالات به IP‌های خصوصی روی پورت‌های VPN | `/proc/net/tcp` | + +**بررسی‌های syscall غیرسنتی (`nativeDetectVpnSyscalls`):** + +بررسی‌ها از طریق درخواست‌های مستقیم netlink و اتصالات probe. وقتی مجوزها وجود نداشته باشند `unavailable` برمی‌گرداند: + +| بررسی | روش | چه چیزی تشخیص می‌دهد | +|-------|-----|---------------------| +| RTM_GETRULE | dump قوانین مسیریابی سیاستی | قوانین مسیریابی سیاستی VPN | +| RTM_GETQDISC | dump انضباط صف | تونل‌های qdisc VPN | +| RTM_GETNEIST | dump جدول همسایه | آدرس‌های MAC پنهان (LLADDR صفر) | +| TCP_INFO MSS | اتصال به 8.8.8.8:443، خواندن `snd_mss` | کاهش MSS (شاخص تونل) | +| SO_BINDTODEVICE | probe bind به رابط غیرموجود | VPN hook intercepting setsockopt | +| اتصال پورت loopback | تلاش برای اشغال پورت‌های 51820/1194/443/8443 | تداخل پورت (listener VPN) | +| BPF OBJ GET | تلاش برای باز کردن نقشه‌های `/sys/fs/bpf/` | دسترسی به نقشه‌های BPF netd | +| IP_RECVERR | probe setsockopt IP_RECVERR | VPN hook intercepting IP_RECVERR | + +اطمینان بالا: `vpn_policy_rules`، `hidden_mac_neighbors`، `tcp_mss_low`، `loopback_port_conflict`، `bpf_map_accessible`. + +#### 12.6 تشخیص‌دهنده عمیق VPN (`VpnNativeDetectorChecker`) + + +بررسی‌های جدید در زیربخش جداگانه‌ای در دسته Native و در ۴ زیرمجموعه گروه‌بندی شده‌اند. داده‌ها از متد JNI جدید `nativeDetectVpnDetector()` با پیشوند `vdet|` می‌آید: + +**نشانه‌های مستقیم (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `sysfs_vpn_leak` | نشت tun/wg/ppp/xfrm از طریق sysfs | `/sys/class/net`، `/sys/devices/virtual/net`، `/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | اینترفیس‌های VPN در لیست `getifaddrs()` | `getifaddrs()` | +| `sysclassnet_vpn` | اینترفیس‌های VPN در `/sys/class/net` | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | اینترفیس‌های VPN از طریق netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | اینترفیس‌های VPN در `/proc/net/if_inet6` | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | مسیرهای VPN در `/proc/net/ipv6_route` | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | ترافیک VPN (RX/TX) در `/proc/net/dev` | `/proc/net/dev` | +| `ifindexname_vpn` | اینترفیس‌های VPN از طریق `if_indextoname()` | پیمایش ifindex با `if_indextoname()` | +| `vpn_policy_rules_netlink` | قوانین مسیریابی VPN policy (table 100–200، oif=tun) | Netlink `RTM_GETRULE` dump | +**نشانه‌های شبکه (Network signs) — `EvidenceSource.NATIVE_SOCKET`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `fib_trie_denied` | `/proc/net/fib_trie` در دسترس نیست (SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink مسدود شده (SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` به tun + تایید `getsockopt` | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` IP خصوصی VPN را برمی‌گرداند | `getsockname()` روی سوکت UDP | +| `udp_port_conflict_physical` | تداخل پورت UDP (500/4500/1194/1701/51820) روی IP فیزیکی | `bind()` روی IP فیزیکی | +| `route_count` | تعداد مسیرها و اینترفیس‌های یکتا | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | عدم تطابق تعداد iface بین bind-probe و RTM_GETLINK | `if_indextoname()` در مقابل `RTM_GETLINK` | +**نشانه‌های غیرمستقیم (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | موفقیت/شکست ارسال 1500 بایت روی UDP | `sendto()` 1500 بایت | +| `normal_pmtu` | Path MTU اینترفیس فیزیکی اصلی | `fetchMtu()` از طریق `getifaddrs()` | +| `timing_oracle` | چرخه‌های ARM CNTVCT برای `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) | +| `backpressure` | نرخ انتقال زیر 50000 بسته UDP با پشتیبانی لغو اسکن | `sendto()` غیرمسدودکننده + بررسی لغو هر 64 بسته | +| `gso_failed` / `gso_send_failed` / `gso_ok` | تشخیص قابلیت UDP GSO؛ نتیجه مدرک VPN نیست | `UDP_SEGMENT=1200` + ارسال 4800 بایت | +| `hw_timestamp` | تایم‌استمپینگ سخت‌افزاری (`SIOCSHWTSTAMP`، `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**پراbe‌های محیطی (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:** + +| بررسی (kind) | چه چیزی جستجو می‌شود | منبع | +|--------------|---------------------|------| +| `traceroute_denied` | تست traceroute (TTL=1 UDP) مسدود شده | `setsockopt(IP_TTL=1)` + `sendto()` | + +اطمینان بالا (→ `detected = true`): `sysfs_vpn_leak`، `getifaddrs_vpn`، `sysclassnet_vpn`، `rtm_getlink_vpn`، `proc_if_inet6_vpn`، `proc_ipv6_route_vpn`، `proc_net_dev_vpn`، `ifindexname_vpn`، `vpn_policy_rules_netlink`، `bindtodevice_leak`، `getsockname_leak`، `udp_port_conflict_physical`. اندازه‌گیری‌های خام و نتایج GSO اطلاعاتی هستند؛ سایر ناهنجاری‌ها → `needsReview`. + --- ## نتیجه نهایی (`VerdictEngine`) @@ -546,7 +648,7 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می - `geoHit` = `GeoIP.outsideRu == true` (به جز رومینگ) - `directHit` = detected-evidence از `DIRECT_NETWORK_CAPABILITIES` یا `SYSTEM_PROXY` -- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH` +- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH` یا `NATIVE_SOCKET` با اطمینان بالا | Geo | Direct | Indirect | Verdict | |-----|--------|----------|---------| @@ -587,6 +689,12 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می ./gradlew assembleDebug ``` +## حالت‌های جزئیات نتیجه + +در «تنظیمات ← ظاهر» سه حالت وجود دارد. «ساده» نتیجه را بدون متن‌های فنی سطح پایین نشان می‌دهد، «عادی» نمایش فعلی را حفظ می‌کند و «پیشرفته» داده‌های فنیِ جمع‌شده را به‌صورت بسته در هر کارت اضافه می‌کند. حالت در لحظه شروع بررسی ثابت می‌شود؛ بنابراین تغییر تنظیم فقط بر بررسی بعدی اثر دارد و بررسی در حال اجرا یا تمام‌شده را دوباره ترسیم نمی‌کند. این انتخاب بر خود بررسی‌ها، حکم نهایی، زمان‌سنج‌ها یا رفتار شبکه اثر ندارد. + +تصویر فنی فقط برای بررسی‌ای ساخته می‌شود که در حالت پیشرفته آغاز شده باشد. داده فقط در حافظه فرایند می‌ماند، با لغو یا شروع بررسی تازه پاک می‌شود و وارد خروجی JSON/Markdown نمی‌شود. سقف هر ورودی 64 KiB و سقف کل بررسی 512 KiB است و برش داده صریحاً نمایش داده می‌شود. سرآیندهای مجوز و cookie، گذرواژه، توکن، UUID، کلیدها، اطلاعات کاربر URI و پارامترهای حساس query پیش از ذخیره حذف می‌شوند. در حالت حریم خصوصی همه نشانی‌های IPv4/IPv6 نیز پوشانده می‌شوند. پاسخ کامل Clash برای `/configs`، `/connections` و `/proxies`، UUID/کلید عمومی Xray، BSSID و شناسه سلول ذخیره نمی‌شود. + --- ## قدردانی diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 869f672..5697080 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -502,7 +502,7 @@ TLS 步骤使用信任所有证书的 X.509 manager,因为目标是检测 DPI #### 12.2 host-route /32 启发式 -在路由表(NETLINK)中查找非默认路由,其目标为公开可路由地址,前缀长度为 `/32`(IPv4)或 `/128`(IPv6),且经由物理接口(`wlan0`、`rmnet`、`eth0`)。这是 VPN 客户端绕过隧道直连 VPN 服务器 IP 的经典主机路由,会泄露真实 VPN 服务器地址。结果:`detected = true`(`EvidenceSource.NATIVE_ROUTE`)。 +在路由表(NETLINK)中查找非默认路由,其目标为公开可路由地址,前缀长度为 `/32`(IPv4)或 `/128`(IPv6),且经由物理接口(`wlan0`、`rmnet`、`eth0`)。这是 VPN 客户端绕过隧道直连 VPN 服务器 IP 的经典主机路由,会泄露真实 VPN 服务器地址。`local` 表中的条目以及内核在调制解调器接口(`rmnet*`、`ccmni*`、`pdp*`、`seth*`)上创建的 link 路由会被排除,因为运营商会独立于 VPN 使用这些路由进行内部服务寻址。这些接口上的静态全局 host-route 仍会作为候选项。结果:`detected = true`(`EvidenceSource.NATIVE_ROUTE`)。 #### 12.3 模拟器检测器 @@ -520,6 +520,109 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*` 满足其中任一条件均会产生 `needsReview = true`(`EvidenceSource.SANDBOX_ISOLATION`),**不会**产生 `detected`。 +#### 12.5 VPN 信号 (`evaluateVpnSignals`) + +通过原生 JNI 调用进行全面的 VPN 检测。所有检查均可在**未 root** 的设备上运行——当权限不足(SELinux/capabilities)时,检查标记为 `unavailable` 而不会崩溃。 + +**属性和文件 (`nativeDetectVpnProperties`):** + +| 检查 | 查找内容 | 来源 | +|------|----------|------| +| DNS 属性 | `net.dns1-4`、`net.vpn.dns1-2`、`dhcp.tun0.dns1-2` | `__system_property_get` | +| VPN 属性 | `net.vpn.default_iface`、`vpn.enable`、`net.tun0.dns1-2`、`net.ppp0.dns1-2` | `__system_property_get` | +| vpnhide 文件 | `/data/local/vpnhide`、`/data/adb/vpnhide`、`/data/local/bypass` 等 | `access(F_OK)` | +| LSPosed/Xposed | `/data/adb/lspd`、`/data/adb/modules/lsposed`、`/data/adb/ksu/modules/lsposed` | `access(F_OK)` | +| Hook 属性 | `persist.sys.lspd`、`persist.sys.lsposed`、`ro.lsposed.hidden` | `__system_property_get` | + +高置信度:`vpn_prop`、`vpnhide`、`hook_prop`。中等:其余。 + +**通过 /proc 泄漏 (`nativeDetectVpnLeaks`):** + +| 检查 | 查找内容 | 来源 | +|------|----------|------| +| TCP VPN 端口 | 端口 443、1194、51820、8443、1723、500、4500 上的连接 | `/proc/net/tcp[6]` | +| UDP VPN 端口 | 端口 51820(WireGuard)、1194(OpenVPN)、500、4500 上的套接字 | `/proc/net/udp[6]` | +| if_inet6 | `/proc/net/if_inet6` 中的 tun/wg/ppp/tap 接口 | `/proc/net/if_inet6` | +| 路由 VPN | 通过 tun/wg/ppp/tap 的路由 | `/proc/net/route` | +| FIB trie | `/32 host` 条目(非 LOCAL)——VPN 主机路由 | `/proc/net/fib_trie` | + +高置信度:`udp_vpn_port`、`route_vpn_iface`、`arp_vpn_iface`、`inet6_vpn_iface`。 + +**高级检查 (`nativeDetectVpnAdvanced`):** + +| 检查 | 查找内容 | 来源 | +|------|----------|------| +| ARP VPN | tun/wg/ppp 接口上的条目 | `/proc/net/arp` | +| Sysctl | `rp_filter=0`、`ip_forward=1`、`forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` 等 | +| ESTABLISHED VPN | 到私有 IP 的 VPN 端口连接 | `/proc/net/tcp` | + +**非常规 syscall 检查 (`nativeDetectVpnSyscalls`):** + +通过直接 netlink 请求和探测连接进行检查。权限不足时返回 `unavailable`: + +| 检查 | 方法 | 检测内容 | +|------|------|----------| +| RTM_GETRULE | Netlink dump 路由策略规则 | VPN 策略路由规则 | +| RTM_GETQDISC | Netlink dump 队列规则 | VPN qdisc 隧道 | +| RTM_GETNEIGH | Netlink dump 邻居表 | 隐藏的 MAC 地址(零 LLADDR) | +| TCP_INFO MSS | 连接到 8.8.8.8:443,读取 `snd_mss` | MSS 降低(隧道指标) | +| SO_BINDTODEVICE | 探测绑定到不存在的接口 | VPN hook 拦截 setsockopt | +| Loopback 端口绑定 | 尝试占用端口 51820/1194/443/8443 | 端口冲突(VPN 监听器) | +| BPF OBJ GET | 尝试打开 `/sys/fs/bpf/` 映射 | 访问 netd BPF 映射 | +| IP_RECVERR | 探测 setsockopt IP_RECVERR | VPN hook 拦截 IP_RECVERR | + +高置信度:`vpn_policy_rules`、`hidden_mac_neighbors`、`tcp_mss_low`、`loopback_port_conflict`、`bpf_map_accessible`。 + +#### 12.6 深度 VPN 检测器 (`VpnNativeDetectorChecker`) + +新检测器位于 Native 类别下的独立子章节,分为 4 个子类别。数据来自新的 JNI 方法 `nativeDetectVpnDetector()`,行前缀为 `vdet|`: + +**直接迹象(Direct signs)— `EvidenceSource.NATIVE_INTERFACE`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `sysfs_vpn_leak` | 通过 sysfs 泄漏 tun/wg/ppp/xfrm | `/sys/class/net`、`/sys/devices/virtual/net`、`/proc/sys/net/ipv4|6/conf|neigh` | +| `getifaddrs_vpn` | `getifaddrs()` 列表中的 VPN 接口 | `getifaddrs()` | +| `sysclassnet_vpn` | `/sys/class/net` 中的 VPN 接口 | `stat("/sys/class/net/")` | +| `rtm_getlink_vpn` | 通过 netlink RTM_GETLINK 的 VPN 接口 | Netlink `RTM_GETLINK` dump | +| `proc_if_inet6_vpn` | `/proc/net/if_inet6` 中的 VPN 接口 | `/proc/net/if_inet6` | +| `proc_ipv6_route_vpn` | `/proc/net/ipv6_route` 中的 VPN 路由 | `/proc/net/ipv6_route` | +| `proc_net_dev_vpn` | `/proc/net/dev` 中的 VPN 流量(RX/TX) | `/proc/net/dev` | +| `ifindexname_vpn` | 通过 `if_indextoname()` 的 VPN 接口 | `if_indextoname()` ifindex 扫描 | +| `vpn_policy_rules_netlink` | VPN policy 路由规则(table 100–200,oif=tun) | Netlink `RTM_GETRULE` dump | + +**网络迹象(Network signs)— `EvidenceSource.NATIVE_SOCKET`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `fib_trie_denied` | `/proc/net/fib_trie` 不可用(SELinux EACCES) | `fopen("/proc/net/fib_trie")` | +| `inet_diag_denied` | inet_diag netlink 被拒绝(SELinux) | `socket(NETLINK_SOCK_DIAG)` | +| `bindtodevice_leak` | `SO_BINDTODEVICE` 到 tun + `getsockopt` 确认 | `setsockopt(SO_BINDTODEVICE)` | +| `getsockname_leak` | `getsockname()` 返回私有 VPN IP | UDP 套接字上的 `getsockname()` | +| `udp_port_conflict_physical` | 物理 IP 上的 UDP 端口冲突(500/4500/1194/1701/51820) | 物理 IP 上的 `bind()` | +| `route_count` | 路由数量和唯一接口数量 | Netlink `RTM_GETROUTE` dump | +| `trim_oracle` | bind-probe 与 RTM_GETLINK 接口数不匹配 | `if_indextoname()` 对比 `RTM_GETLINK` | + +**间接迹象(Indirect signs)— `EvidenceSource.NATIVE_ROUTE`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `pmtu_mss_combined` | UDP PMTU + TCP MSS(tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` | +| `udp_pmtu_ok` / `udp_pmtu_fail` | 通过 UDP 发送 1500 字节成功/失败 | `sendto()` 1500 字节 | +| `normal_pmtu` | 主物理接口的路径 MTU | 通过 `getifaddrs()` 的 `fetchMtu()` | +| `timing_oracle` | `sendto()` 的 ARM CNTVCT 周期(最小/最大/平均) | `mrs cntvct_el0`(aarch64) | +| `backpressure` | 50000 个 UDP 包下的吞吐量,支持扫描取消 | 非阻塞 `sendto()` 突发,每 64 个包检查一次取消状态 | +| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO 能力诊断;结果不是 VPN 证据 | `UDP_SEGMENT=1200` + 发送 4800 字节 | +| `hw_timestamp` | 硬件时间戳(`SIOCSHWTSTAMP`、`SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` | + +**环境探测(Environment probes)— `EvidenceSource.NATIVE_INTERFACE`:** + +| 检查 (kind) | 查找内容 | 来源 | +|-------------|----------|------| +| `traceroute_denied` | traceroute 探测(TTL=1 UDP)被阻止 | `setsockopt(IP_TTL=1)` + `sendto()` | + +高置信度(→ `detected = true`):`sysfs_vpn_leak`、`getifaddrs_vpn`、`sysclassnet_vpn`、`rtm_getlink_vpn`、`proc_if_inet6_vpn`、`proc_ipv6_route_vpn`、`proc_net_dev_vpn`、`ifindexname_vpn`、`vpn_policy_rules_netlink`、`bindtodevice_leak`、`getsockname_leak`、`udp_port_conflict_physical`。原始测量值和 GSO 结果仅供参考;其他异常 → `needsReview`。 + --- ## 结论 (`VerdictEngine`) @@ -545,7 +648,7 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*` - `geoHit` = `GeoIP.outsideRu == true`(漫游除外) - `directHit` = 来自 `DIRECT_NETWORK_CAPABILITIES` 或 `SYSTEM_PROXY` 的 detected evidence -- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES`、`ACTIVE_VPN`、`NETWORK_INTERFACE`、`ROUTING`、`DNS`、`PROXY_TECHNICAL_SIGNAL`、`NATIVE_INTERFACE`、`NATIVE_ROUTE`、`NATIVE_JVM_MISMATCH` 的 detected evidence +- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES`、`ACTIVE_VPN`、`NETWORK_INTERFACE`、`ROUTING`、`DNS`、`PROXY_TECHNICAL_SIGNAL`、`NATIVE_INTERFACE`、`NATIVE_ROUTE`、`NATIVE_JVM_MISMATCH` 的 detected evidence,或高置信度 `NATIVE_SOCKET` | Geo | Direct | Indirect | Verdict | |-----|--------|----------|---------| @@ -586,6 +689,12 @@ JNI 检查(`nativeDetectEmulator`):QEMU 系统属性(`ro.kernel.qemu*` ./gradlew assembleDebug ``` +## 结果详细程度 + +“设置 → 外观”提供三种模式。“简单”用易懂文字显示结果,不展示底层技术字符串;“普通”保留现有展示;“高级”在每张结果卡中增加默认折叠的技术数据。模式会在检查开始时固定,因此修改设置只影响下一次检查,不会重绘正在运行或已经完成的结果。该模式不改变检查、最终判定、超时或网络行为。 + +技术快照只为以高级模式启动的检查创建。它仅保存在进程内存中,在取消或开始新检查时清除,也不会写入 JSON/Markdown 导出。单条记录上限为 64 KiB,单次检查总上限为 512 KiB,截断会被明确标记。保存前会删除授权和 Cookie 请求头、密码、令牌、UUID、密钥、URI 用户信息及敏感查询参数。启用隐私模式时还会屏蔽所有 IPv4/IPv6 地址。不会保存 Clash `/configs`、`/connections`、`/proxies` 的完整响应、Xray UUID/公钥、BSSID 或蜂窝标识符。 + --- ## 致谢 diff --git a/fastlane/metadata/android/en-US/changelogs/21000.txt b/fastlane/metadata/android/en-US/changelogs/21000.txt new file mode 100644 index 0000000..112fa7d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21000.txt @@ -0,0 +1,5 @@ +1. Added a deep native VPN detector with interface, routing, socket, sysfs, /proc, netlink, PMTU/MSS and environment probes. Permission-limited probes are reported as unavailable instead of crashing. +2. Added Simple, Normal and Advanced result display modes. Advanced mode records bounded, sanitized technical diagnostics in memory and keeps them out of JSON/Markdown exports. +3. Improved verdict handling and diagnostics with new native evidence categories, structured trace capture, safer cancellation, and expanded tests. +4. Fixed false positives from carrier service routes on modem interfaces such as ccmni*, rmnet*, pdp* and seth*, while preserving static global host-route detection. +5. Improved IPv4/IPv6 address handling and the presentation of detailed and plain-language scan results. diff --git a/fastlane/metadata/android/fa/changelogs/21000.txt b/fastlane/metadata/android/fa/changelogs/21000.txt new file mode 100644 index 0000000..7f3a96f --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/21000.txt @@ -0,0 +1,5 @@ +1. یک آشکارساز عمیق VPN بومی با بررسی رابط‌ها، مسیرها، سوکت‌ها، sysfs، /proc، netlink، PMTU/MSS و محیط اضافه شد. بررسی‌های محدودشده به‌دلیل مجوز به‌صورت «در دسترس نیست» گزارش می‌شوند و باعث کرش نمی‌شوند. +2. سه حالت نمایش نتیجه «ساده»، «عادی» و «پیشرفته» اضافه شد. در حالت پیشرفته، داده‌های فنی محدود و پاک‌سازی‌شده فقط در حافظه نگه‌داری می‌شوند و وارد خروجی JSON/Markdown نمی‌شوند. +3. منطق حکم و عیب‌یابی بهبود یافت: دسته‌های جدید شواهد بومی، ثبت ساختاریافته ردپا، لغو امن‌تر بررسی و مجموعه تست گسترده‌تر اضافه شد. +4. مثبت کاذب ناشی از مسیرهای سرویس اپراتور روی رابط‌های مودم مانند `ccmni*`، `rmnet*`، `pdp*` و `seth*` برطرف شد؛ host-routeهای سراسری ایستا همچنان بررسی می‌شوند. +5. پردازش نشانی‌های IPv4/IPv6 و نمایش نتایج فنی و قابل‌فهم بررسی بهبود یافت. diff --git a/fastlane/metadata/android/ru-RU/changelogs/21000.txt b/fastlane/metadata/android/ru-RU/changelogs/21000.txt new file mode 100644 index 0000000..eb1cfce --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/21000.txt @@ -0,0 +1,5 @@ +1. Добавлен глубокий нативный VPN-детектор с проверками интерфейсов, маршрутов, сокетов, sysfs, /proc, netlink, PMTU/MSS и окружения. Проверки, ограниченные правами доступа, помечаются как недоступные и не приводят к сбою. +2. Добавлены три режима отображения результатов: «Простой», «Обычный» и «Расширенный». В расширенном режиме ограниченные и очищенные технические данные хранятся в памяти и не попадают в JSON/Markdown-экспорт. +3. Улучшены вердикты и диагностика: добавлены новые категории нативных свидетельств, структурированный сбор трассировки, более безопасная отмена проверки и расширенный набор тестов. +4. Исправлены ложные срабатывания на служебных маршрутах операторов мобильной связи через интерфейсы `ccmni*`, `rmnet*`, `pdp*` и `seth*`; статические глобальные host-route по-прежнему проверяются. +5. Улучшена обработка IPv4/IPv6-адресов и отображение подробных и понятных результатов проверки. diff --git a/fastlane/metadata/android/zh-CN/changelogs/21000.txt b/fastlane/metadata/android/zh-CN/changelogs/21000.txt new file mode 100644 index 0000000..e7d75f4 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/21000.txt @@ -0,0 +1,5 @@ +1. 新增深度原生 VPN 检测器,检查网络接口、路由、套接字、sysfs、/proc、netlink、PMTU/MSS 和设备环境。受权限限制的检查会标记为不可用,不会导致崩溃。 +2. 新增“简单”“普通”和“高级”三种结果显示模式。高级模式只在内存中保存有上限且已清理的技术诊断数据,不会写入 JSON/Markdown 导出。 +3. 改进判定和诊断:新增原生证据类别、结构化诊断跟踪、更安全的取消处理以及更完整的测试。 +4. 修复调制解调器接口(如 `ccmni*`、`rmnet*`、`pdp*` 和 `seth*`)上的运营商服务路由导致的误报;静态全局 host-route 仍会继续检查。 +5. 改进 IPv4/IPv6 地址处理,以及详细结果和易懂结果的显示。