mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-11 01:59:35 +00:00
Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fc12181a | ||
|
|
fa7c2fbc72 | ||
|
|
0d24988aed | ||
|
|
640e445a92 | ||
|
|
e5faf94563 | ||
|
|
219518c53f | ||
|
|
52b4550427 |
73 changed files with 6755 additions and 179 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,4 +23,5 @@ release.jks
|
|||
!CONTRIBUTING.md
|
||||
!app/src/test/resources/export/golden/*.md
|
||||
/.codex
|
||||
.marketplace-keys/
|
||||
.marketplace-keys/
|
||||
app-release/
|
||||
114
README.md
114
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/<if>")` |
|
||||
| `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 и идентификаторы сот не сохраняются.
|
||||
|
||||
---
|
||||
|
||||
## Благодарности
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ int ProgressCallback(
|
|||
curl_off_t /* dlnow */,
|
||||
curl_off_t /* ultotal */,
|
||||
curl_off_t /* ulnow */) {
|
||||
auto* state = static_cast<NativeCurlCancellationState*>(clientp);
|
||||
const auto* state = static_cast<const NativeCurlCancellationState*>(clientp);
|
||||
return state != nullptr && state->cancelled.load() ? 1 : 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScanExecutionContext?>
|
|||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ internal class SettingsCustomCheckEditorFragment :
|
|||
|
||||
// Override expansion listener to track openSectionIds
|
||||
sectionView.findViewById<View>(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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Inet4Address>().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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ActiveVpnApp>,
|
||||
): 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>): 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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<com.notcvnt.rknhardering.probe.NativeVpnPropertyFinding>,
|
||||
leaks: List<com.notcvnt.rknhardering.probe.NativeVpnLeakFinding>,
|
||||
advanced: List<com.notcvnt.rknhardering.probe.NativeVpnAdvancedFinding>,
|
||||
syscalls: List<com.notcvnt.rknhardering.probe.NativeVpnSyscallFinding>,
|
||||
): PartialOutcome {
|
||||
val findings = mutableListOf<Finding>()
|
||||
val evidence = mutableListOf<EvidenceItem>()
|
||||
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)}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EvidenceSource> = 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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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|<kind>|<detail>".
|
||||
*/
|
||||
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<Finding>,
|
||||
evidence: MutableList<EvidenceItem>,
|
||||
) {
|
||||
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<Finding>()
|
||||
val evidence = mutableListOf<EvidenceItem>()
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DiagnosticEntry>,
|
||||
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<DiagnosticEntry>()
|
||||
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>(
|
||||
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("(?<![A-Za-z0-9])(?:\\d{1,3}\\.){3}\\d{1,3}(?![A-Za-z0-9])")
|
||||
private val ipv6 = Regex("(?<![A-Za-z0-9])(?:[0-9A-Fa-f]{0,4}:){2,}[0-9A-Fa-f]{0,4}(?![A-Za-z0-9])")
|
||||
|
||||
fun sanitize(value: String, privacyMode: Boolean): String {
|
||||
var safe = sensitiveHeader.replace(value) { match ->
|
||||
"${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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EvidenceSource> = emptySet(),
|
||||
)
|
||||
|
||||
data class VerdictDecision(
|
||||
val verdict: Verdict,
|
||||
val rule: VerdictRuleCode,
|
||||
val participants: List<VerdictParticipant>,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ object NetworkInterfacePatterns {
|
|||
|
||||
val IPSEC_INTERFACE_PATTERN: Regex = Regex("^(ipsec|xfrm).*")
|
||||
|
||||
private val CELLULAR_MODEM_INTERFACES: List<Regex> = listOf(
|
||||
Regex("^rmnet.*"),
|
||||
Regex("^ccmni.*"),
|
||||
Regex("^ccemni.*"),
|
||||
Regex("^pdp.*"),
|
||||
Regex("^seth.*"),
|
||||
)
|
||||
|
||||
val STANDARD_INTERFACES: List<Regex> = 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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,8 +73,33 @@ object ResolverNetworkStack {
|
|||
cancellationSignal: ScanCancellationSignal? = null,
|
||||
): List<InetAddress> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<NativeRouteEntry> {
|
||||
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<String>): List<NativeVpnPropertyFinding> {
|
||||
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<NativeVpnPropertyFinding> {
|
||||
val rows = NativeSignsBridge.detectVpnProperties()
|
||||
return parseVpnPropertyFindings(rows)
|
||||
}
|
||||
|
||||
fun parseVpnLeakFindings(rows: Array<String>): List<NativeVpnLeakFinding> {
|
||||
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<NativeVpnLeakFinding> {
|
||||
val rows = NativeSignsBridge.detectVpnLeaks()
|
||||
return parseVpnLeakFindings(rows)
|
||||
}
|
||||
|
||||
fun parseVpnAdvancedFindings(rows: Array<String>): List<NativeVpnAdvancedFinding> {
|
||||
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<NativeVpnAdvancedFinding> {
|
||||
val rows = NativeSignsBridge.detectVpnAdvanced()
|
||||
return parseVpnAdvancedFindings(rows)
|
||||
}
|
||||
|
||||
fun parseVpnSyscallFindings(rows: Array<String>): List<NativeVpnSyscallFinding> {
|
||||
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<NativeVpnSyscallFinding> {
|
||||
val rows = NativeSignsBridge.detectVpnSyscalls()
|
||||
return parseVpnSyscallFindings(rows)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>)? = null
|
||||
|
||||
@Volatile
|
||||
internal var detectVpnPropertiesOverride: (() -> Array<String>)? = null
|
||||
|
||||
@Volatile
|
||||
internal var detectVpnLeaksOverride: (() -> Array<String>)? = null
|
||||
|
||||
@Volatile
|
||||
internal var detectVpnAdvancedOverride: (() -> Array<String>)? = null
|
||||
|
||||
@Volatile
|
||||
internal var detectVpnSyscallsOverride: (() -> Array<String>)? = null
|
||||
|
||||
@Volatile
|
||||
internal var detectVpnDetectorOverride: ((ScanCancellationSignal?) -> Array<String>)? = null
|
||||
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
|
||||
|
|
@ -76,69 +94,159 @@ object NativeSignsBridge {
|
|||
}
|
||||
|
||||
fun getIfAddrs(): Array<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
return traceArray("detectVpnProperties") {
|
||||
detectVpnPropertiesOverride?.invoke()
|
||||
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnProperties() }.getOrDefault(emptyArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun detectVpnLeaks(): Array<String> {
|
||||
return traceArray("detectVpnLeaks") {
|
||||
detectVpnLeaksOverride?.invoke()
|
||||
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnLeaks() }.getOrDefault(emptyArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun detectVpnAdvanced(): Array<String> {
|
||||
return traceArray("detectVpnAdvanced") {
|
||||
detectVpnAdvancedOverride?.invoke()
|
||||
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnAdvanced() }.getOrDefault(emptyArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun detectVpnSyscalls(): Array<String> {
|
||||
return traceArray("detectVpnSyscalls") {
|
||||
detectVpnSyscallsOverride?.invoke()
|
||||
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnSyscalls() }.getOrDefault(emptyArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun detectVpnDetector(cancellationSignal: ScanCancellationSignal? = null): Array<String> {
|
||||
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<String>,
|
||||
): Array<String> = traceValue(source, target, block) { it.joinToString("\n") }
|
||||
|
||||
private fun <T> 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<String>
|
||||
private external fun nativeDetectRoot(): Array<String>
|
||||
private external fun nativeDetectEmulator(): Array<String>
|
||||
private external fun nativeDetectVpnProperties(): Array<String>
|
||||
private external fun nativeDetectVpnLeaks(): Array<String>
|
||||
private external fun nativeDetectVpnAdvanced(): Array<String>
|
||||
private external fun nativeDetectVpnSyscalls(): Array<String>
|
||||
private external fun nativeDetectVpnDetector(cancellationSignal: ScanCancellationSignal?): Array<String>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ object StunBindingClient {
|
|||
timeoutMs: Int = 3_000,
|
||||
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
|
||||
): Result<BindingResult> {
|
||||
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<Inet4Address>()
|
||||
val ipv6Addresses = allAddresses.filterIsInstance<Inet6Address>()
|
||||
|
|
@ -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<BindingResult> {
|
||||
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<Result<BindingResult>>,
|
||||
) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import java.util.Locale
|
|||
|
||||
object TunProbeDiagnosticsFormatter {
|
||||
|
||||
private const val NONE = "<none>"
|
||||
|
||||
fun format(
|
||||
diagnostics: TunProbeDiagnostics,
|
||||
settings: CheckSettings,
|
||||
|
|
@ -109,8 +111,8 @@ object TunProbeDiagnosticsFormatter {
|
|||
builder.appendLine("available: true")
|
||||
builder.appendLine("interfaceName: ${path.interfaceName ?: "<missing>"}")
|
||||
builder.appendLine("selectedMode: ${path.selectedMode?.name ?: "NONE"}")
|
||||
builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: "<none>"}")
|
||||
builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: "<none>"}")
|
||||
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) ?: "<none>"}")
|
||||
builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: "<none>"}")
|
||||
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) ?: "<none>"}"
|
||||
PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: NONE}"
|
||||
PublicIpProbeStatus.FAILED,
|
||||
PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: "<none>"}"
|
||||
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 { "<none>" }
|
||||
.ifBlank { NONE }
|
||||
"DIRECT preset=${settings.resolverConfig.preset.name} servers=$servers"
|
||||
}
|
||||
DnsResolverMode.DOH -> {
|
||||
val bootstrap = settings.resolverConfig.effectiveDohBootstrapHosts()
|
||||
.joinToString(", ") { maskIp(it) }
|
||||
.ifBlank { "<none>" }
|
||||
"DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: "<none>"} bootstrap=$bootstrap"
|
||||
.ifBlank { NONE }
|
||||
"DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: NONE} bootstrap=$bootstrap"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class XrayApiClient(
|
|||
deadlineMs: Long = 600,
|
||||
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
|
||||
): Result<XrayApiScanResult> = 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<XrayStatsSummary> = 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()
|
||||
|
|
|
|||
|
|
@ -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<SimpleResultCause> = 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<CallTransportLeakResult>,
|
||||
stunGroups: List<StunProbeGroupResult>,
|
||||
): 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<EvidenceSource>): 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
|
||||
}
|
||||
|
|
@ -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<View, Int>,
|
||||
)
|
||||
|
||||
private val renderedCards = mutableMapOf<ViewGroup, RenderedCard>()
|
||||
|
||||
fun render(body: ViewGroup, whatWasChecked: String, model: SimpleCardModel) {
|
||||
clear(body)
|
||||
val originalVisibility = IdentityHashMap<View, Int>()
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<View>()
|
||||
|
||||
fun render(body: ViewGroup, entries: List<DiagnosticEntry>, 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<DiagnosticEntry>,
|
||||
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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,71 @@
|
|||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainer"
|
||||
app:strokeColor="?attr/colorOutlineVariant"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_result_display_mode"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipGroupResultDisplayMode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipResultDisplaySimple"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/settings_result_display_mode_simple_content_description"
|
||||
android:text="@string/settings_result_display_mode_simple" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipResultDisplayNormal"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/settings_result_display_mode_normal_content_description"
|
||||
android:text="@string/settings_result_display_mode_normal" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipResultDisplayAdvanced"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/settings_result_display_mode_advanced_content_description"
|
||||
android:text="@string/settings_result_display_mode_advanced" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textResultDisplayModeDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@
|
|||
<string name="settings_theme_light">روشن</string>
|
||||
<string name="settings_theme_dark">تیره</string>
|
||||
<string name="settings_theme_system">سیستمی</string>
|
||||
<string name="settings_result_display_mode">جزئیات نتایج</string>
|
||||
<string name="settings_result_display_mode_simple">ساده</string>
|
||||
<string name="settings_result_display_mode_normal">معمولی</string>
|
||||
<string name="settings_result_display_mode_advanced">پیشرفته</string>
|
||||
<string name="settings_result_display_mode_simple_desc">خلاصههای ساده و قابل فهم، بدون جزئیات فنی. برای بررسی بعدی اعمال میشود.</string>
|
||||
<string name="settings_result_display_mode_normal_desc">نمای استاندارد نتایج و جزئیات. برای بررسی بعدی اعمال میشود.</string>
|
||||
<string name="settings_result_display_mode_advanced_desc">نتایج استاندارد همراه با دادههای فنی بازشدنی. برای بررسی بعدی اعمال میشود.</string>
|
||||
<string name="settings_result_display_mode_simple_content_description">حالت نمایش ساده نتایج</string>
|
||||
<string name="settings_result_display_mode_normal_content_description">حالت نمایش معمولی نتایج</string>
|
||||
<string name="settings_result_display_mode_advanced_content_description">حالت نمایش پیشرفته نتایج</string>
|
||||
<string name="settings_language">زبان</string>
|
||||
<string name="settings_language_system">سیستمی</string>
|
||||
<string name="settings_language_en">English</string>
|
||||
|
|
@ -591,7 +601,7 @@
|
|||
<string name="settings_value_network_all">همه درخواستها</string>
|
||||
<string name="settings_value_network_disabled">غیرفعال</string>
|
||||
<string name="settings_value_privacy_masking">پنهانسازی IP</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
|
||||
|
||||
<!-- NativeSignsChecker -->
|
||||
<string name="checker_native_category_name">نشانههای بومی β</string>
|
||||
|
|
@ -639,6 +649,64 @@
|
|||
<string name="checker_native_isolation_clone">برنامه در یک محفظه شبیهسازی/dual-app اجرا میشود (کاربر %1$d)؛ VPN در پروفایل اصلی قابل تشخیص نیست</string>
|
||||
<string name="checker_native_isolation_work_profile">برنامه در یک پروفایل کاری مدیریتشده اجرا میشود؛ VPN در پروفایل شخصی بدون روت قابل تشخیص نیست</string>
|
||||
|
||||
<string name="checker_native_vpn_signal">سیگنال VPN: %1$s</string>
|
||||
<string name="vpn_desc_prop">ویژگی سیستمی VPN شناسایی شد</string>
|
||||
<string name="vpn_desc_vpnhide">فایل مخفیسازی VPN یافت شد</string>
|
||||
<string name="vpn_desc_hook">ویژگی فریمورک hook شناسایی شد</string>
|
||||
<string name="vpn_desc_dns">ویژگی DNS به VPN اشاره میکند</string>
|
||||
<string name="vpn_desc_file">فایل ابزار VPN یافت شد</string>
|
||||
<string name="vpn_desc_lsposed">محیط LSPosed/Xposed شناسایی شد</string>
|
||||
<string name="vpn_desc_tcp_port">اتصال TCP روی پورت VPN</string>
|
||||
<string name="vpn_desc_udp_port">سوکت UDP روی پورت VPN</string>
|
||||
<string name="vpn_desc_inet6">رابط VPN IPv6 در /proc</string>
|
||||
<string name="vpn_desc_route">مسیر از طریق رابط VPN</string>
|
||||
<string name="vpn_desc_arp">رکورد ARP روی رابط VPN</string>
|
||||
<string name="vpn_desc_forwarding">Forwarding IP فعال است</string>
|
||||
<string name="vpn_desc_rpfilter">فیلتر reverse path غیرفعال است</string>
|
||||
<string name="vpn_desc_established">اتصال برقرار به IP خصوصی روی پورت VPN</string>
|
||||
<string name="vpn_desc_policy_rules">قوانین مسیریابی VPN یافت شد</string>
|
||||
<string name="vpn_desc_qdisc"> queue discipline VPN شناسایی شد</string>
|
||||
<string name="vpn_desc_hidden_mac">آدرسهای MAC پنهان در جدول همسایه</string>
|
||||
<string name="vpn_desc_mss">MSS به طور مشکوکی پایین (احتمالاً تونل)</string>
|
||||
<string name="vpn_desc_bindtodevice">setsockopt رهگیری شد (hook VPN)</string>
|
||||
<string name="vpn_desc_port_conflict">تداخل پورت روی loopback (listener VPN)</string>
|
||||
<string name="vpn_desc_bpf">دسترسی به نقشههای BPF netd</string>
|
||||
<string name="vpn_desc_recverr">IP_RECVERR رهگیری شد (hook VPN)</string>
|
||||
<string name="vpn_desc_sysfs_leak">رابط VPN از طریق sysfs نشت کرد</string>
|
||||
<string name="vpn_desc_getifaddrs">رابط VPN در getifaddrs()</string>
|
||||
<string name="vpn_desc_sysclassnet">رابط VPN در /sys/class/net</string>
|
||||
<string name="vpn_desc_rtm_getlink">رابط VPN در RTM_GETLINK</string>
|
||||
<string name="vpn_desc_proc_if_inet6">رابط VPN IPv6 در /proc/net/if_inet6</string>
|
||||
<string name="vpn_desc_proc_ipv6_route">مسیر VPN IPv6 در /proc/net/ipv6_route</string>
|
||||
<string name="vpn_desc_proc_net_dev">ترافیک VPN در /proc/net/dev</string>
|
||||
<string name="vpn_desc_fib_trie">دسترسی به /proc/net/fib_trie مسدود شد (SELinux)</string>
|
||||
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE رابط VPN را نشت داد</string>
|
||||
<string name="vpn_desc_inet_diag">inet_diag netlink مسدود شد (SELinux)</string>
|
||||
<string name="vpn_desc_getsockname">getsockname() IP VPN را نشت داد</string>
|
||||
<string name="vpn_desc_policy_rules_netlink">قوانین مسیریابی VPN از طریق netlink</string>
|
||||
<string name="vpn_desc_route_count">شمارش مسیرها و رابطهای ناشناس</string>
|
||||
<string name="vpn_desc_udp_port_physical">تداخل پورت UDP روی رابط فیزیکی</string>
|
||||
<string name="vpn_desc_trim_oracle">عدم تطابق bind probe و RTM_GETLINK</string>
|
||||
<string name="vpn_desc_ifindexname">رابط VPN از طریق if_indextoname</string>
|
||||
<string name="vpn_desc_pmtu_mss">تحلیل UDP PMTU و TCP MSS</string>
|
||||
<string name="vpn_desc_udp_pmtu_ok">ارسال UDP موفق (بدون مشکل PMTU)</string>
|
||||
<string name="vpn_desc_udp_pmtu_fail">ارسال UDP ناموفق (مشکل PMTU)</string>
|
||||
<string name="vpn_desc_normal_pmtu">تشخیص path MTU عادی</string>
|
||||
<string name="vpn_desc_traceroute">تست traceroute (ICMP/TTL)</string>
|
||||
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
|
||||
<string name="vpn_desc_backpressure">Backpressure تحت بار مداوم</string>
|
||||
<string name="vpn_desc_gso_failed">پشتیبانی GSO در دسترس نیست (فقط تشخیصی)</string>
|
||||
<string name="vpn_desc_gso_send_failed">ارسال GSO ناموفق بود (فقط تشخیصی)</string>
|
||||
<string name="vpn_desc_gso_ok">GSO پشتیبانی میشود (رابط عادی)</string>
|
||||
<string name="vpn_desc_hw_timestamp">بررسی تایماستمپینگ سختافزاری</string>
|
||||
<string name="vpn_status_not_found">یافت نشد</string>
|
||||
<string name="vpn_status_unavailable">غیرقابل دسترس (بدون مجوز)</string>
|
||||
<string name="checker_vpn_detector_category_name">تشخیصدهنده VPN (عمیق)</string>
|
||||
<string name="checker_vpn_detector_unavailable">کتابخانه تشخیصدهنده VPN بارگذاری نشد</string>
|
||||
<string name="checker_vpn_detector_unavailable_with_reason">کتابخانه تشخیصدهنده VPN بارگذاری نشد: %1$s</string>
|
||||
<string name="checker_vpn_detector_no_anomalies">نشانه عمیق VPN یافت نشد</string>
|
||||
|
||||
|
||||
<!-- App update checker -->
|
||||
<string name="update_dialog_title">بهروزرسانی موجود است</string>
|
||||
<string name="update_dialog_message">نسخه نصبشده: %1$s\nآخرین نسخه: %2$s</string>
|
||||
|
|
@ -932,4 +1000,111 @@
|
|||
<string name="export_duration_unit_ms">میلیثانیه</string>
|
||||
<string name="export_available">در دسترس</string>
|
||||
<string name="export_unavailable">در دسترس نیست</string>
|
||||
<string name="simple_result_checked_label">چه چیزی بررسی شد</string>
|
||||
<string name="simple_result_outcome_label">نتیجه</string>
|
||||
<string name="simple_result_explanation_label">معنای این نتیجه</string>
|
||||
<string name="simple_result_running">در حال بررسی</string>
|
||||
<string name="simple_result_clean">مشکلی پیدا نشد</string>
|
||||
<string name="simple_result_review">کمی تردید وجود دارد</string>
|
||||
<string name="simple_result_detected">یک مشکل پیدا شد</string>
|
||||
<string name="simple_result_error">بررسی ممکن نشد</string>
|
||||
<string name="simple_result_disabled">این بررسی غیرفعال است</string>
|
||||
<string name="simple_result_explanation_running">نتیجه پس از پایان این بخش نمایش داده میشود.</string>
|
||||
<string name="simple_result_explanation_clean">نشانههای بررسیشده عادی به نظر میرسند.</string>
|
||||
<string name="simple_result_explanation_error">این بخش اطلاعات کافی برنگرداند. بررسیهای دیگر همچنان میتوانند کامل شوند.</string>
|
||||
<string name="simple_result_explanation_review">یک نشانه غیرعادی پیدا شد، اما بهتنهایی برای تأیید مشکل کافی نیست.</string>
|
||||
<string name="simple_result_area_public_address">نشانه غیرعادی در اطلاعات نشانی عمومی شبکه دیده شد.</string>
|
||||
<string name="simple_result_area_device_network">نشانه غیرعادی در وضعیت شبکه دستگاه دیده شد.</string>
|
||||
<string name="simple_result_area_local_app">نشانه غیرعادی به یک برنامه شبکه نصبشده یا فعال مربوط بود.</string>
|
||||
<string name="simple_result_area_local_proxy">نشانه غیرعادی در سرویس محلی تغییر مسیر ترافیک دیده شد.</string>
|
||||
<string name="simple_result_area_network_route">نشانه غیرعادی در مسیر ترافیک شبکه دیده شد.</string>
|
||||
<string name="simple_result_area_call_route">نشانه غیرعادی در مسیر شبکه تماسها دیده شد.</string>
|
||||
<string name="simple_result_area_location">نشانه غیرعادی هنگام مقایسه اطلاعات شبکه و مکان دیده شد.</string>
|
||||
<string name="simple_result_area_remote_site">نشانه غیرعادی هنگام ارتباط با یک سرویس دوردست دیده شد.</string>
|
||||
<string name="simple_result_area_device_environment">نشانه غیرعادی در محیط سیستمی دستگاه دیده شد.</string>
|
||||
<string name="simple_result_area_multiple">نشانههای مرتبط در چند بخش بررسی شبکه دیده شدند.</string>
|
||||
<string name="simple_result_extra_information">اطلاعات تکمیلی: این کارت بهتنهایی رأی نهایی را تغییر نمیدهد.</string>
|
||||
|
||||
<string name="simple_title_geo">مکان ظاهری اتصال</string>
|
||||
<string name="simple_title_ip_comparison">یکسان بودن نشانی نزد سرویسها</string>
|
||||
<string name="simple_title_cdn">پاسخ سایتهای دوردست</string>
|
||||
<string name="simple_title_direct">تنظیمات آشکار شبکه</string>
|
||||
<string name="simple_title_indirect">نشانههای دیگر شبکه</string>
|
||||
<string name="simple_title_native">نشانههای شبکه در سطح دستگاه</string>
|
||||
<string name="simple_title_call_transport">مسیر ترافیک تماس</string>
|
||||
<string name="simple_title_icmp">سازگاری پاسخهای شبکه</string>
|
||||
<string name="simple_title_rtt">مقایسه تأخیر اتصال</string>
|
||||
<string name="simple_title_location">هماهنگی شبکه و مکان</string>
|
||||
<string name="simple_title_bypass">خروج ترافیک از مسیری دیگر</string>
|
||||
<string name="simple_title_reachability">دسترسی به سایتهای انتخابشده</string>
|
||||
|
||||
<string name="simple_checked_geo">کشور و شبکهای که از نشانی عمومی اتصال دیده میشود.</string>
|
||||
<string name="simple_checked_ip_comparison">اینکه سرویسهای مختلف نشانی عمومی سازگاری گزارش میکنند.</string>
|
||||
<string name="simple_checked_cdn">اینکه سرویسهای دوردست انتخابشده اطلاعات مورد انتظار را برمیگردانند.</string>
|
||||
<string name="simple_checked_direct">تنظیمات شبکهای که تغییر مسیر ترافیک را مستقیماً گزارش میکنند.</string>
|
||||
<string name="simple_checked_indirect">نشانههای پشتیبان در برنامهها، رابطها و پیکربندی شبکه.</string>
|
||||
<string name="simple_checked_native">نشانههای سطح دستگاه که پنهانکردن آنها برای برنامهها دشوارتر است.</string>
|
||||
<string name="simple_checked_call_transport">اینکه ترافیک تماس از مسیر شبکه غیرمنتظرهای عبور میکند.</string>
|
||||
<string name="simple_checked_icmp">اینکه پاسخهای شبکه با سرویس مقصد سازگار هستند.</string>
|
||||
<string name="simple_checked_rtt">اینکه تأخیرهای اتصال بهشکل غیرعادی متفاوت هستند.</string>
|
||||
<string name="simple_checked_location">اینکه اطلاعات شبکه محلی و مکان با هم سازگار هستند.</string>
|
||||
<string name="simple_checked_bypass">اینکه ترافیک میتواند خارج از مسیر محافظتشده مورد انتظار خارج شود.</string>
|
||||
<string name="simple_checked_reachability">اینکه سایتهای انتخابشده در این شبکه مطابق انتظار رفتار میکنند.</string>
|
||||
|
||||
<string name="simple_verdict_hard_bypass">یک نشانه مستقیم نشان داد که ترافیک میتواند از مسیر غیرمنتظرهای عبور کند.</string>
|
||||
<string name="simple_verdict_address_conflict">چند مشاهده نشانی بهگونهای ناسازگارند که به تغییر مسیر ترافیک اشاره میکند.</string>
|
||||
<string name="simple_verdict_location_conflict">مکان اتصال با مکان شبکه محلی سازگار نیست.</string>
|
||||
<string name="simple_verdict_hosting_review">نشانی شبیه اتصال میزبانیشده یا واسطهای است، اما نشانههای دیگر قطعی نیستند.</string>
|
||||
<string name="simple_verdict_combined_signals">رأی از ترکیب مکان اتصال و نشانههای شبکه دستگاه بهدست آمده است.</string>
|
||||
<string name="simple_verdict_review_signals">یک یا چند نشانه پشتیبان نیاز به توجه دارند، اما بهتنهایی مشکل را ثابت نمیکنند.</string>
|
||||
<string name="simple_verdict_general">رأی بر پایه نتایج ساختیافته بررسیهای کاملشده است.</string>
|
||||
|
||||
<string name="technical_data_title">دادههای فنی</string>
|
||||
<string name="technical_data_expand_content_description">باز کردن دادههای فنی</string>
|
||||
<string name="technical_data_collapse_content_description">بستن دادههای فنی</string>
|
||||
<string name="technical_data_empty">برای این کارت داده فنی ثبت نشده است.</string>
|
||||
<string name="technical_data_empty_body">بدنهای ثبت نشده است.</string>
|
||||
<string name="technical_data_source">منبع: %1$s</string>
|
||||
<string name="technical_data_target">هدف: %1$s</string>
|
||||
<string name="technical_data_status">وضعیت: %1$s</string>
|
||||
<string name="technical_data_duration">مدت: %1$d میلیثانیه</string>
|
||||
<string name="technical_data_size">اندازه ذخیرهشده: %1$s</string>
|
||||
<string name="technical_data_truncated">کوتاه شده (اندازه اصلی: %1$s)</string>
|
||||
<string name="technical_data_run_truncated">کوتاه شده: سقف کلی ۵۱۲ KiB برای این بررسی پر شد.</string>
|
||||
<string name="technical_data_bytes">%1$d بایت</string>
|
||||
<string name="technical_data_copy_block">کپی بلوک</string>
|
||||
<string name="technical_data_copy_block_content_description">کپی دادههای فنی از %1$s</string>
|
||||
<string name="technical_data_copied">دادههای فنی کپی شد</string>
|
||||
<string name="simple_cause_ip_ru_services_disagree">سرویسهای روسی تشخیص IP نشانیهای عمومی متفاوتی گزارش کردند.</string>
|
||||
<string name="simple_cause_ip_non_ru_services_disagree">سرویسهای بینالمللی تشخیص IP نشانیهای عمومی متفاوتی گزارش کردند.</string>
|
||||
<string name="simple_cause_ip_groups_disagree">سرویسهای روسی و بینالمللی نشانیهای عمومی متفاوتی گزارش کردند.</string>
|
||||
<string name="simple_cause_ip_families_differ">سرویسها خانوادههای متفاوت نشانی را برگرداندند، بنابراین پاسخها مستقیماً قابل مقایسه نیستند.</string>
|
||||
<string name="simple_cause_ip_partial_response">بعضی سرویسهای تشخیص نشانی عمومی پاسخ ندادند و مقایسه ناقص است.</string>
|
||||
<string name="simple_cause_ip_unavailable">هیچ سرویس تشخیص نشانی عمومی پاسخ قابل استفادهای نداد.</string>
|
||||
<string name="simple_cause_cdn_responses_differ">سایتهای آزمایششده از طریق نشانیهای عمومی متفاوتی باز شدند.</string>
|
||||
<string name="simple_cause_cdn_partial_response">بعضی سایتهای آزمایششده برای مقایسه کامل داده کافی ندادند.</string>
|
||||
<string name="simple_cause_public_ip_location">اطلاعات کشور یا مالک شبکه نشانی عمومی به VPN، پراکسی، میزبانی یا کشوری غیرمنتظره اشاره دارد.</string>
|
||||
<string name="simple_cause_vpn_network_state">اندروید گزارش میدهد که شبکه فعال از VPN عبور میکند.</string>
|
||||
<string name="simple_cause_active_vpn_app">یک برنامه اکنون اتصال VPN را برقرار نگه داشته است.</string>
|
||||
<string name="simple_cause_system_proxy">تنظیم سراسری هدایت ترافیک در اندروید فعال است.</string>
|
||||
<string name="simple_cause_local_proxy">یک سرویس محلی که میتواند ترافیک شبکه را هدایت کند روی دستگاه پاسخ میدهد.</string>
|
||||
<string name="simple_cause_vpn_interface">یک رابط شبکه ویژه ترافیک تونلی فعال است.</string>
|
||||
<string name="simple_cause_vpn_route">جدول مسیریابی دستگاه ترافیک را از تونل یا دروازهای غیرمنتظره عبور میدهد.</string>
|
||||
<string name="simple_cause_dns_redirection">درخواستهای نام دامنه از مسیر شبکه متفاوت یا هدایتشدهای پردازش میشوند.</string>
|
||||
<string name="simple_cause_traffic_bypass">ترافیک خارج از VPN از مسیر شبکه یا نشانی عمومی متفاوتی استفاده کرد.</string>
|
||||
<string name="simple_cause_proxy_auth_required">پراکسی محلی احراز هویت خواست و هدف آن قابل تأیید نبود.</string>
|
||||
<string name="simple_cause_location_conflict">دادههای سیمکارت، شبکه همراه و مکان نشانی عمومی مسیر یکسانی را نشان نمیدهند.</string>
|
||||
<string name="simple_cause_icmp_response_mismatch">پاسخ بسته آزمایشی با بسته ارسالشده مطابقت نداشت.</string>
|
||||
<string name="simple_cause_rtt_route_pattern">زمان پاسخ نشان میدهد برخی مقصدها شاید از مسیر شبکه متفاوتی استفاده کنند.</string>
|
||||
<string name="simple_cause_telegram_call_path">بررسی تماس تلگرام مسیر شبکه غیرعادی یا ناقصی پیدا کرد.</string>
|
||||
<string name="simple_cause_whatsapp_call_path">بررسی تماس واتساپ مسیر شبکه غیرعادی یا ناقصی پیدا کرد.</string>
|
||||
<string name="simple_cause_call_check_unavailable">سرویسهای بررسی تماس داده کافی برای بررسی مسیر ندادند.</string>
|
||||
<string name="simple_cause_domain_dns_mismatch">نام سایت برخلاف نتیجه مورد انتظار حل شد.</string>
|
||||
<string name="simple_cause_domain_tcp_mismatch">اتصال به سایت برخلاف نتیجه مورد انتظار برقرار شد.</string>
|
||||
<string name="simple_cause_domain_tls_mismatch">اتصال امن برخلاف نتیجه مورد انتظار کامل شد.</string>
|
||||
<string name="simple_cause_public_data_unavailable">سرویسهای تشخیص نشانی عمومی داده کافی برنگرداندند.</string>
|
||||
<string name="simple_cause_network_data_unavailable">اندروید برای تکمیل این بررسی داده کافی از وضعیت شبکه نداد.</string>
|
||||
<string name="simple_cause_remote_data_unavailable">سرویسهای آزمایشی راه دور برای تکمیل این بررسی داده کافی ندادند.</string>
|
||||
<string name="simple_cause_device_data_unavailable">اندروید برای تکمیل این بررسی داده سیستمی کافی از شبکه نداد.</string>
|
||||
<string name="simple_cause_device_environment">در محیط دستگاه تغییر یا محدودیتی وجود دارد که میتواند بررسیهای شبکه را تغییر دهد.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@
|
|||
<string name="settings_theme_light">Светлая</string>
|
||||
<string name="settings_theme_dark">Тёмная</string>
|
||||
<string name="settings_theme_system">Системная</string>
|
||||
<string name="settings_result_display_mode">Подробность результатов</string>
|
||||
<string name="settings_result_display_mode_simple">Простой</string>
|
||||
<string name="settings_result_display_mode_normal">Обычный</string>
|
||||
<string name="settings_result_display_mode_advanced">Расширенный</string>
|
||||
<string name="settings_result_display_mode_simple_desc">Понятные краткие итоги без технических подробностей. Применится к следующей проверке.</string>
|
||||
<string name="settings_result_display_mode_normal_desc">Стандартный вид и состав результатов. Применится к следующей проверке.</string>
|
||||
<string name="settings_result_display_mode_advanced_desc">Обычные результаты с раскрываемыми техническими данными. Применится к следующей проверке.</string>
|
||||
<string name="settings_result_display_mode_simple_content_description">Простой режим отображения результатов</string>
|
||||
<string name="settings_result_display_mode_normal_content_description">Обычный режим отображения результатов</string>
|
||||
<string name="settings_result_display_mode_advanced_content_description">Расширенный режим отображения результатов</string>
|
||||
<string name="settings_language">Язык</string>
|
||||
<string name="settings_language_system">Системный</string>
|
||||
<string name="settings_language_en">English</string>
|
||||
|
|
@ -581,7 +591,7 @@
|
|||
<string name="settings_value_network_all">Все запросы</string>
|
||||
<string name="settings_value_network_disabled">Отключено</string>
|
||||
<string name="settings_value_privacy_masking">Маскировка IP</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
|
||||
|
||||
<!-- NativeSignsChecker -->
|
||||
<string name="checker_native_category_name">Нативные признаки β</string>
|
||||
|
|
@ -660,6 +670,65 @@
|
|||
<string name="checker_native_isolation_clone">Приложение работает в клонированном/dual-app контейнере (пользователь %1$d); VPN в основном профиле не обнаруживается</string>
|
||||
<string name="checker_native_isolation_work_profile">Приложение работает в управляемом рабочем профиле; VPN в личном профиле не обнаруживается без root</string>
|
||||
|
||||
<!-- NativeSignsChecker: VPN сигналы -->
|
||||
<string name="checker_native_vpn_signal">VPN сигнал: %1$s</string>
|
||||
<string name="vpn_desc_prop">Обнаружены VPN-свойства системы</string>
|
||||
<string name="vpn_desc_vpnhide">Файл сокрытия VPN</string>
|
||||
<string name="vpn_desc_hook">Свойства хук-фреймворков</string>
|
||||
<string name="vpn_desc_dns">DNS-свойства указывают на VPN</string>
|
||||
<string name="vpn_desc_file">Файлы VPN-инструментов</string>
|
||||
<string name="vpn_desc_lsposed">Обнаружена LSPosed/Xposed среда</string>
|
||||
<string name="vpn_desc_tcp_port">TCP-соединение на VPN-порту</string>
|
||||
<string name="vpn_desc_udp_port">UDP-сокет на VPN-порту</string>
|
||||
<string name="vpn_desc_inet6">IPv6-интерфейс VPN в /proc</string>
|
||||
<string name="vpn_desc_route">Маршрут через VPN-интерфейс</string>
|
||||
<string name="vpn_desc_arp">ARP-запись VPN-интерфейса</string>
|
||||
<string name="vpn_desc_forwarding">IP-форвардинг включён</string>
|
||||
<string name="vpn_desc_rpfilter">Reverse path filter отключен</string>
|
||||
<string name="vpn_desc_established">Установленное соединение к приватному IP через VPN-порт</string>
|
||||
<string name="vpn_desc_policy_rules">Найдены VPN-правила роутинга</string>
|
||||
<string name="vpn_desc_qdisc">Обнаружена VPN queue discipline</string>
|
||||
<string name="vpn_desc_hidden_mac">Скрытые MAC-адреса в таблице соседей</string>
|
||||
<string name="vpn_desc_mss">Подозрительно низкий MSS (возможно туннель)</string>
|
||||
<string name="vpn_desc_bindtodevice">Перехват setsockopt (VPN-хук)</string>
|
||||
<string name="vpn_desc_port_conflict">Конфликт портов на loopback (VPN listener)</string>
|
||||
<string name="vpn_desc_bpf">Доступ к BPF-картам netd</string>
|
||||
<string name="vpn_desc_recverr">Перехват IP_RECVERR (VPN-хук)</string>
|
||||
<string name="vpn_desc_sysfs_leak">VPN-интерфейс утёк через sysfs</string>
|
||||
<string name="vpn_desc_getifaddrs">VPN-интерфейс в getifaddrs()</string>
|
||||
<string name="vpn_desc_sysclassnet">VPN-интерфейс в /sys/class/net</string>
|
||||
<string name="vpn_desc_rtm_getlink">VPN-интерфейс в RTM_GETLINK</string>
|
||||
<string name="vpn_desc_proc_if_inet6">VPN IPv6-интерфейс в /proc/net/if_inet6</string>
|
||||
<string name="vpn_desc_proc_ipv6_route">VPN IPv6-маршрут в /proc/net/ipv6_route</string>
|
||||
<string name="vpn_desc_proc_net_dev">VPN-трафик в /proc/net/dev</string>
|
||||
<string name="vpn_desc_fib_trie">Доступ к /proc/net/fib_trie запрещён (SELinux)</string>
|
||||
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE утек интерфейс VPN</string>
|
||||
<string name="vpn_desc_inet_diag">inet_diag netlink запрещён (SELinux)</string>
|
||||
<string name="vpn_desc_getsockname">getsockname() утек IP VPN</string>
|
||||
<string name="vpn_desc_policy_rules_netlink">VPN-правила роутинга через netlink</string>
|
||||
<string name="vpn_desc_route_count">Подсчёт маршрутов и анонимных интерфейсов</string>
|
||||
<string name="vpn_desc_udp_port_physical">Конфликт UDP-порта на физическом интерфейсе</string>
|
||||
<string name="vpn_desc_trim_oracle">Расхождение bind probe и RTM_GETLINK</string>
|
||||
<string name="vpn_desc_ifindexname">VPN-интерфейс через if_indextoname</string>
|
||||
<string name="vpn_desc_pmtu_mss">Анализ UDP PMTU и TCP MSS</string>
|
||||
<string name="vpn_desc_udp_pmtu_ok">UDP-отправка успешна (нет PMTU-проблемы)</string>
|
||||
<string name="vpn_desc_udp_pmtu_fail">UDP-отправка не удалась (PMTU-проблема)</string>
|
||||
<string name="vpn_desc_normal_pmtu">Обнаружение нормального path MTU</string>
|
||||
<string name="vpn_desc_traceroute">Traceroute-проба (ICMP/TTL)</string>
|
||||
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
|
||||
<string name="vpn_desc_backpressure">Backpressure при постоянной нагрузке</string>
|
||||
<string name="vpn_desc_gso_failed">Поддержка GSO недоступна (только диагностика)</string>
|
||||
<string name="vpn_desc_gso_send_failed">GSO-отправка не удалась (только диагностика)</string>
|
||||
<string name="vpn_desc_gso_ok">GSO поддерживается (нормальный интерфейс)</string>
|
||||
<string name="vpn_desc_hw_timestamp">Проверка аппаратного таймстампинга</string>
|
||||
<string name="vpn_status_not_found">не обнаружено</string>
|
||||
<string name="vpn_status_unavailable">недоступно (нет прав)</string>
|
||||
<string name="checker_vpn_detector_category_name">VPN Детектор (глубокий)</string>
|
||||
<string name="checker_vpn_detector_unavailable">Библиотека VPN-детектора не загружена</string>
|
||||
<string name="checker_vpn_detector_unavailable_with_reason">Библиотека VPN-детектора не загружена: %1$s</string>
|
||||
<string name="checker_vpn_detector_no_anomalies">Глубоких признаков VPN не обнаружено</string>
|
||||
|
||||
|
||||
<!-- App update checker -->
|
||||
<string name="update_dialog_title">Доступно обновление</string>
|
||||
<string name="update_dialog_message">Установленная версия: %1$s\nАктуальная версия: %2$s</string>
|
||||
|
|
@ -953,4 +1022,111 @@
|
|||
<string name="export_duration_unit_ms">мс</string>
|
||||
<string name="export_available">доступен</string>
|
||||
<string name="export_unavailable">недоступен</string>
|
||||
<string name="simple_result_checked_label">Что проверялось</string>
|
||||
<string name="simple_result_outcome_label">Результат</string>
|
||||
<string name="simple_result_explanation_label">Что это значит</string>
|
||||
<string name="simple_result_running">Идёт проверка</string>
|
||||
<string name="simple_result_clean">Проблем не найдено</string>
|
||||
<string name="simple_result_review">Есть сомнение</string>
|
||||
<string name="simple_result_detected">Обнаружена проблема</string>
|
||||
<string name="simple_result_error">Не удалось проверить</string>
|
||||
<string name="simple_result_disabled">Эта проверка отключена</string>
|
||||
<string name="simple_result_explanation_running">Результат появится после завершения этой части проверки.</string>
|
||||
<string name="simple_result_explanation_clean">Проверенные признаки выглядят обычно.</string>
|
||||
<string name="simple_result_explanation_error">Эта часть не дала достаточно данных. Остальные проверки могут продолжиться.</string>
|
||||
<string name="simple_result_explanation_review">Найден необычный признак, но его недостаточно, чтобы подтвердить проблему.</string>
|
||||
<string name="simple_result_area_public_address">Необычный признак найден в данных о публичном сетевом адресе.</string>
|
||||
<string name="simple_result_area_device_network">Необычный признак найден в состоянии сети устройства.</string>
|
||||
<string name="simple_result_area_local_app">Необычный признак связан с установленным или активным сетевым приложением.</string>
|
||||
<string name="simple_result_area_local_proxy">Необычный признак найден в локальной службе перенаправления трафика.</string>
|
||||
<string name="simple_result_area_network_route">Необычный признак найден на пути сетевого трафика.</string>
|
||||
<string name="simple_result_area_call_route">Необычный признак найден на сетевом пути звонков.</string>
|
||||
<string name="simple_result_area_location">Необычный признак найден при сравнении данных сети и местоположения.</string>
|
||||
<string name="simple_result_area_remote_site">Необычный признак найден при обращении к удалённому сервису.</string>
|
||||
<string name="simple_result_area_device_environment">Необычный признак найден в системном окружении устройства.</string>
|
||||
<string name="simple_result_area_multiple">Связанные признаки найдены в нескольких частях проверки сети.</string>
|
||||
<string name="simple_result_extra_information">Дополнительная информация: эта карточка сама по себе не меняет итоговый вердикт.</string>
|
||||
|
||||
<string name="simple_title_geo">Где находится подключение</string>
|
||||
<string name="simple_title_ip_comparison">Одинаковый ли адрес видят сервисы</string>
|
||||
<string name="simple_title_cdn">Как отвечают удалённые сайты</string>
|
||||
<string name="simple_title_direct">Видимые настройки сети</string>
|
||||
<string name="simple_title_indirect">Другие сетевые признаки</string>
|
||||
<string name="simple_title_native">Системные сетевые признаки</string>
|
||||
<string name="simple_title_call_transport">Путь трафика звонков</string>
|
||||
<string name="simple_title_icmp">Согласованность сетевых ответов</string>
|
||||
<string name="simple_title_rtt">Сравнение задержек подключения</string>
|
||||
<string name="simple_title_location">Совпадение сети и местоположения</string>
|
||||
<string name="simple_title_bypass">Выход трафика другим путём</string>
|
||||
<string name="simple_title_reachability">Доступность выбранных сайтов</string>
|
||||
|
||||
<string name="simple_checked_geo">Страна и сеть, которые видны по публичному адресу подключения.</string>
|
||||
<string name="simple_checked_ip_comparison">Показывают ли разные сервисы согласованный публичный адрес.</string>
|
||||
<string name="simple_checked_cdn">Возвращают ли выбранные удалённые сервисы ожидаемые данные.</string>
|
||||
<string name="simple_checked_direct">Настройки сети, прямо сообщающие о перенаправлении трафика.</string>
|
||||
<string name="simple_checked_indirect">Дополнительные признаки в приложениях, интерфейсах и настройках сети.</string>
|
||||
<string name="simple_checked_native">Системные признаки, которые приложениям сложнее скрыть.</string>
|
||||
<string name="simple_checked_call_transport">Идёт ли трафик звонков по неожиданному сетевому пути.</string>
|
||||
<string name="simple_checked_icmp">Согласуются ли сетевые ответы с сервисом, к которому обращается приложение.</string>
|
||||
<string name="simple_checked_rtt">Отличаются ли задержки подключения необычным образом.</string>
|
||||
<string name="simple_checked_location">Согласуются ли данные локальной сети и местоположения.</string>
|
||||
<string name="simple_checked_bypass">Может ли трафик выйти вне ожидаемого защищённого пути.</string>
|
||||
<string name="simple_checked_reachability">Ведут ли себя выбранные сайты ожидаемо в этой сети.</string>
|
||||
|
||||
<string name="simple_verdict_hard_bypass">Прямой признак показал, что трафик может идти неожиданным путём.</string>
|
||||
<string name="simple_verdict_address_conflict">Несколько наблюдений адреса расходятся так, что это указывает на перенаправление трафика.</string>
|
||||
<string name="simple_verdict_location_conflict">Местоположение подключения противоречит местоположению локальной сети.</string>
|
||||
<string name="simple_verdict_hosting_review">Адрес похож на серверное или пересылаемое подключение, но остальные признаки не дают уверенного ответа.</string>
|
||||
<string name="simple_verdict_combined_signals">Вердикт основан на сочетании местоположения подключения и сетевых признаков устройства.</string>
|
||||
<string name="simple_verdict_review_signals">Один или несколько дополнительных признаков требуют внимания, но сами по себе не подтверждают проблему.</string>
|
||||
<string name="simple_verdict_general">Вердикт основан на структурированных результатах завершённых проверок.</string>
|
||||
|
||||
<string name="technical_data_title">Технические данные</string>
|
||||
<string name="technical_data_expand_content_description">Развернуть технические данные</string>
|
||||
<string name="technical_data_collapse_content_description">Свернуть технические данные</string>
|
||||
<string name="technical_data_empty">Для этой карточки технические записи не собраны.</string>
|
||||
<string name="technical_data_empty_body">Тело ответа не сохранено.</string>
|
||||
<string name="technical_data_source">Источник: %1$s</string>
|
||||
<string name="technical_data_target">Цель: %1$s</string>
|
||||
<string name="technical_data_status">Статус: %1$s</string>
|
||||
<string name="technical_data_duration">Длительность: %1$d мс</string>
|
||||
<string name="technical_data_size">Сохранённый размер: %1$s</string>
|
||||
<string name="technical_data_truncated">Обрезано (исходный размер: %1$s)</string>
|
||||
<string name="technical_data_run_truncated">Обрезано: достигнут общий лимит 512 КиБ на проверку.</string>
|
||||
<string name="technical_data_bytes">%1$d Б</string>
|
||||
<string name="technical_data_copy_block">Копировать блок</string>
|
||||
<string name="technical_data_copy_block_content_description">Копировать технические данные из %1$s</string>
|
||||
<string name="technical_data_copied">Технические данные скопированы</string>
|
||||
<string name="simple_cause_ip_ru_services_disagree">Российские сервисы определения IP сообщили разные публичные адреса.</string>
|
||||
<string name="simple_cause_ip_non_ru_services_disagree">Зарубежные сервисы определения IP сообщили разные публичные адреса.</string>
|
||||
<string name="simple_cause_ip_groups_disagree">Российские и зарубежные сервисы сообщили разные публичные адреса.</string>
|
||||
<string name="simple_cause_ip_families_differ">Сервисы вернули адреса разных семейств, поэтому ответы нельзя сравнить напрямую.</string>
|
||||
<string name="simple_cause_ip_partial_response">Часть сервисов определения публичного адреса не ответила, поэтому сравнение неполное.</string>
|
||||
<string name="simple_cause_ip_unavailable">Ни один сервис определения публичного адреса не вернул пригодный ответ.</string>
|
||||
<string name="simple_cause_cdn_responses_differ">Проверенные сайты открылись через разные публичные адреса.</string>
|
||||
<string name="simple_cause_cdn_partial_response">Часть проверенных сайтов не вернула достаточно данных для полного сравнения.</string>
|
||||
<string name="simple_cause_public_ip_location">Данные о стране или владельце публичного адреса указывают на VPN, прокси, хостинг либо неожиданную страну.</string>
|
||||
<string name="simple_cause_vpn_network_state">Android сообщает, что активная сеть работает через VPN.</string>
|
||||
<string name="simple_cause_active_vpn_app">Одно из приложений сейчас поддерживает VPN-подключение.</string>
|
||||
<string name="simple_cause_system_proxy">В Android включена системная настройка перенаправления интернет-трафика.</string>
|
||||
<string name="simple_cause_local_proxy">На устройстве отвечает локальная служба, способная перенаправлять сетевой трафик.</string>
|
||||
<string name="simple_cause_vpn_interface">Активен сетевой интерфейс, созданный для туннельного трафика.</string>
|
||||
<string name="simple_cause_vpn_route">Таблица маршрутов устройства направляет трафик через туннель или неожиданный шлюз.</string>
|
||||
<string name="simple_cause_dns_redirection">Запросы доменных имён обрабатываются через другой или перенаправленный сетевой путь.</string>
|
||||
<string name="simple_cause_traffic_bypass">Трафик вне VPN прошёл по другому сетевому пути или получил другой публичный адрес.</string>
|
||||
<string name="simple_cause_proxy_auth_required">Локальный прокси запросил вход, поэтому подтвердить его назначение не удалось.</string>
|
||||
<string name="simple_cause_location_conflict">Данные SIM-карты, мобильной сети и страны публичного адреса описывают разные маршруты.</string>
|
||||
<string name="simple_cause_icmp_response_mismatch">На тестовый пакет пришёл ответ, который не совпадает с отправленным пакетом.</string>
|
||||
<string name="simple_cause_rtt_route_pattern">Время ответа показывает, что часть адресов может использовать другой сетевой маршрут.</string>
|
||||
<string name="simple_cause_telegram_call_path">Проверка связи для звонков Telegram обнаружила необычный или неполный сетевой путь.</string>
|
||||
<string name="simple_cause_whatsapp_call_path">Проверка связи для звонков WhatsApp обнаружила необычный или неполный сетевой путь.</string>
|
||||
<string name="simple_cause_call_check_unavailable">Сервисы проверки связи для звонков не вернули достаточно данных о маршруте.</string>
|
||||
<string name="simple_cause_domain_dns_mismatch">Имя сайта разрешилось не так, как ожидалось.</string>
|
||||
<string name="simple_cause_domain_tcp_mismatch">Соединение с сайтом установилось не так, как ожидалось.</string>
|
||||
<string name="simple_cause_domain_tls_mismatch">Защищённое соединение завершилось не так, как ожидалось.</string>
|
||||
<string name="simple_cause_public_data_unavailable">Сервисы определения публичного адреса не вернули достаточно данных.</string>
|
||||
<string name="simple_cause_network_data_unavailable">Android не предоставил достаточно данных о состоянии сети для этой проверки.</string>
|
||||
<string name="simple_cause_remote_data_unavailable">Удалённые тестовые сервисы не вернули достаточно данных для этой проверки.</string>
|
||||
<string name="simple_cause_device_data_unavailable">Android не предоставил достаточно системных данных о сети для этой проверки.</string>
|
||||
<string name="simple_cause_device_environment">В среде устройства есть изменения или ограничения, способные повлиять на сетевые проверки.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@
|
|||
<string name="settings_theme_light">浅色</string>
|
||||
<string name="settings_theme_dark">深色</string>
|
||||
<string name="settings_theme_system">跟随系统</string>
|
||||
<string name="settings_result_display_mode">结果详细程度</string>
|
||||
<string name="settings_result_display_mode_simple">简洁</string>
|
||||
<string name="settings_result_display_mode_normal">普通</string>
|
||||
<string name="settings_result_display_mode_advanced">高级</string>
|
||||
<string name="settings_result_display_mode_simple_desc">使用易懂的摘要,不显示技术细节。将在下次检查时应用。</string>
|
||||
<string name="settings_result_display_mode_normal_desc">使用标准的结果视图和详细信息。将在下次检查时应用。</string>
|
||||
<string name="settings_result_display_mode_advanced_desc">显示标准结果和可展开的技术数据。将在下次检查时应用。</string>
|
||||
<string name="settings_result_display_mode_simple_content_description">简洁结果显示模式</string>
|
||||
<string name="settings_result_display_mode_normal_content_description">普通结果显示模式</string>
|
||||
<string name="settings_result_display_mode_advanced_content_description">高级结果显示模式</string>
|
||||
<string name="settings_language">语言</string>
|
||||
<string name="settings_language_system">系统</string>
|
||||
<string name="settings_language_en">English</string>
|
||||
|
|
@ -589,7 +599,7 @@
|
|||
<string name="settings_value_network_all">所有请求</string>
|
||||
<string name="settings_value_network_disabled">已禁用</string>
|
||||
<string name="settings_value_privacy_masking">IP 掩码</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
|
||||
|
||||
<!-- NativeSignsChecker -->
|
||||
<string name="checker_native_category_name">原生检查 β</string>
|
||||
|
|
@ -637,6 +647,64 @@
|
|||
<string name="checker_native_isolation_clone">应用运行在克隆/双开容器中(用户 %1$d);无法检测主资料中的 VPN</string>
|
||||
<string name="checker_native_isolation_work_profile">应用运行在受管理的工作资料中;无法在无 root 的情况下检测个人资料中的 VPN</string>
|
||||
|
||||
<string name="checker_native_vpn_signal">VPN 信号:%1$s</string>
|
||||
<string name="vpn_desc_prop">检测到 VPN 系统属性</string>
|
||||
<string name="vpn_desc_vpnhide">发现 VPN 隐藏文件</string>
|
||||
<string name="vpn_desc_hook">检测到 hook 框架属性</string>
|
||||
<string name="vpn_desc_dns">DNS 属性指向 VPN</string>
|
||||
<string name="vpn_desc_file">发现 VPN 工具文件</string>
|
||||
<string name="vpn_desc_lsposed">检测到 LSPosed/Xposed 环境</string>
|
||||
<string name="vpn_desc_tcp_port">VPN 端口上的 TCP 连接</string>
|
||||
<string name="vpn_desc_udp_port">VPN 端口上的 UDP 套接字</string>
|
||||
<string name="vpn_desc_inet6">/proc 中的 IPv6 VPN 接口</string>
|
||||
<string name="vpn_desc_route">通过 VPN 接口的路由</string>
|
||||
<string name="vpn_desc_arp">VPN 接口上的 ARP 条目</string>
|
||||
<string name="vpn_desc_forwarding">IP 转发已启用</string>
|
||||
<string name="vpn_desc_rpfilter">反向路径过滤已禁用</string>
|
||||
<string name="vpn_desc_established">到私有 IP 的 VPN 端口已建立连接</string>
|
||||
<string name="vpn_desc_policy_rules">发现 VPN 路由规则</string>
|
||||
<string name="vpn_desc_qdisc">检测到 VPN 队列规则</string>
|
||||
<string name="vpn_desc_hidden_mac">邻居表中隐藏的 MAC 地址</string>
|
||||
<string name="vpn_desc_mss">异常低的 MSS(可能是隧道)</string>
|
||||
<string name="vpn_desc_bindtodevice">setsockopt 被拦截(VPN hook)</string>
|
||||
<string name="vpn_desc_port_conflict">loopback 端口冲突(VPN 监听器)</string>
|
||||
<string name="vpn_desc_bpf">访问 netd BPF 映射</string>
|
||||
<string name="vpn_desc_recverr">IP_RECVERR 被拦截(VPN hook)</string>
|
||||
<string name="vpn_desc_sysfs_leak">VPN 接口通过 sysfs 泄露</string>
|
||||
<string name="vpn_desc_getifaddrs">getifaddrs() 中的 VPN 接口</string>
|
||||
<string name="vpn_desc_sysclassnet">/sys/class/net 中的 VPN 接口</string>
|
||||
<string name="vpn_desc_rtm_getlink">RTM_GETLINK 中的 VPN 接口</string>
|
||||
<string name="vpn_desc_proc_if_inet6">/proc/net/if_inet6 中的 VPN IPv6 接口</string>
|
||||
<string name="vpn_desc_proc_ipv6_route">/proc/net/ipv6_route 中的 VPN IPv6 路由</string>
|
||||
<string name="vpn_desc_proc_net_dev">/proc/net/dev 中的 VPN 流量</string>
|
||||
<string name="vpn_desc_fib_trie">/proc/net/fib_trie 访问被拒绝 (SELinux)</string>
|
||||
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE 泄露 VPN 接口</string>
|
||||
<string name="vpn_desc_inet_diag">inet_diag netlink 被拒绝 (SELinux)</string>
|
||||
<string name="vpn_desc_getsockname">getsockname() 泄露 VPN IP</string>
|
||||
<string name="vpn_desc_policy_rules_netlink">通过 netlink 的 VPN 策略规则</string>
|
||||
<string name="vpn_desc_route_count">路由计数和匿名接口</string>
|
||||
<string name="vpn_desc_udp_port_physical">物理接口上的 UDP 端口冲突</string>
|
||||
<string name="vpn_desc_trim_oracle">bind probe 与 RTM_GETLINK 不符</string>
|
||||
<string name="vpn_desc_ifindexname">通过 if_indextoname 暴露的 VPN 接口</string>
|
||||
<string name="vpn_desc_pmtu_mss">UDP PMTU 和 TCP MSS 分析</string>
|
||||
<string name="vpn_desc_udp_pmtu_ok">UDP 发送成功(无 PMTU 瓶颈)</string>
|
||||
<string name="vpn_desc_udp_pmtu_fail">UDP 发送失败(PMTU 问题)</string>
|
||||
<string name="vpn_desc_normal_pmtu">正常路径 MTU 检测</string>
|
||||
<string name="vpn_desc_traceroute">Traceroute 探测 (ICMP/TTL)</string>
|
||||
<string name="vpn_desc_timing_oracle">ARM CNTVCT 计时神谕</string>
|
||||
<string name="vpn_desc_backpressure">持续突发下的背压</string>
|
||||
<string name="vpn_desc_gso_failed">GSO 支持不可用(仅诊断)</string>
|
||||
<string name="vpn_desc_gso_send_failed">GSO 发送失败(仅诊断)</string>
|
||||
<string name="vpn_desc_gso_ok">支持 GSO(正常接口)</string>
|
||||
<string name="vpn_desc_hw_timestamp">硬件时间戳检查</string>
|
||||
<string name="vpn_status_not_found">未发现</string>
|
||||
<string name="vpn_status_unavailable">不可用(无权限)</string>
|
||||
<string name="checker_vpn_detector_category_name">VPN 检测器(深度)</string>
|
||||
<string name="checker_vpn_detector_unavailable">VPN 检测器库未加载</string>
|
||||
<string name="checker_vpn_detector_unavailable_with_reason">VPN 检测器库未加载:%1$s</string>
|
||||
<string name="checker_vpn_detector_no_anomalies">未检测到深度 VPN 迹象</string>
|
||||
|
||||
|
||||
<!-- App update checker -->
|
||||
<string name="update_dialog_title">有更新可用</string>
|
||||
<string name="update_dialog_message">已安装版本:%1$s\n最新版本:%2$s</string>
|
||||
|
|
@ -930,4 +998,111 @@
|
|||
<string name="export_duration_unit_ms">毫秒</string>
|
||||
<string name="export_available">可用</string>
|
||||
<string name="export_unavailable">不可用</string>
|
||||
<string name="simple_result_checked_label">检查内容</string>
|
||||
<string name="simple_result_outcome_label">结果</string>
|
||||
<string name="simple_result_explanation_label">结果含义</string>
|
||||
<string name="simple_result_running">正在检查</string>
|
||||
<string name="simple_result_clean">未发现问题</string>
|
||||
<string name="simple_result_review">存在疑点</string>
|
||||
<string name="simple_result_detected">发现问题</string>
|
||||
<string name="simple_result_error">无法检查</string>
|
||||
<string name="simple_result_disabled">此检查已关闭</string>
|
||||
<string name="simple_result_explanation_running">这部分检查完成后会显示结果。</string>
|
||||
<string name="simple_result_explanation_clean">已检查的信号看起来正常。</string>
|
||||
<string name="simple_result_explanation_error">这部分未返回足够的信息,其他检查仍可继续完成。</string>
|
||||
<string name="simple_result_explanation_review">检查发现异常信号,但仅凭这一项不足以确认问题。</string>
|
||||
<string name="simple_result_area_public_address">异常信号出现在公网地址信息中。</string>
|
||||
<string name="simple_result_area_device_network">异常信号出现在设备网络状态中。</string>
|
||||
<string name="simple_result_area_local_app">异常信号与已安装或正在运行的网络应用有关。</string>
|
||||
<string name="simple_result_area_local_proxy">异常信号出现在本地流量转发服务中。</string>
|
||||
<string name="simple_result_area_network_route">异常信号出现在网络流量路径中。</string>
|
||||
<string name="simple_result_area_call_route">异常信号出现在通话使用的网络路径中。</string>
|
||||
<string name="simple_result_area_location">比较网络与位置信息时发现异常信号。</string>
|
||||
<string name="simple_result_area_remote_site">连接远程服务时发现异常信号。</string>
|
||||
<string name="simple_result_area_device_environment">异常信号出现在设备系统环境中。</string>
|
||||
<string name="simple_result_area_multiple">网络检查的多个部分出现了相关信号。</string>
|
||||
<string name="simple_result_extra_information">补充信息:此卡片本身不会改变最终结论。</string>
|
||||
|
||||
<string name="simple_title_geo">连接显示的位置</string>
|
||||
<string name="simple_title_ip_comparison">各服务看到的地址是否一致</string>
|
||||
<string name="simple_title_cdn">远程网站如何响应</string>
|
||||
<string name="simple_title_direct">可见的网络设置</string>
|
||||
<string name="simple_title_indirect">其他网络信号</string>
|
||||
<string name="simple_title_native">设备级网络信号</string>
|
||||
<string name="simple_title_call_transport">通话流量路径</string>
|
||||
<string name="simple_title_icmp">网络响应一致性</string>
|
||||
<string name="simple_title_rtt">连接延迟对比</string>
|
||||
<string name="simple_title_location">网络与位置是否一致</string>
|
||||
<string name="simple_title_bypass">流量是否从其他路径离开</string>
|
||||
<string name="simple_title_reachability">所选网站是否可访问</string>
|
||||
|
||||
<string name="simple_checked_geo">公网连接地址所显示的国家和网络。</string>
|
||||
<string name="simple_checked_ip_comparison">不同服务是否报告一致的公网地址。</string>
|
||||
<string name="simple_checked_cdn">所选远程服务是否返回预期信息。</string>
|
||||
<string name="simple_checked_direct">直接表明流量被转发的网络设置。</string>
|
||||
<string name="simple_checked_indirect">应用、接口和网络配置中的辅助信号。</string>
|
||||
<string name="simple_checked_native">应用较难隐藏的设备级信号。</string>
|
||||
<string name="simple_checked_call_transport">通话流量是否经过意外的网络路径。</string>
|
||||
<string name="simple_checked_icmp">网络响应是否与所联系的服务一致。</string>
|
||||
<string name="simple_checked_rtt">连接延迟是否以异常方式出现差异。</string>
|
||||
<string name="simple_checked_location">本地网络与位置信息是否一致。</string>
|
||||
<string name="simple_checked_bypass">流量是否能离开预期的受保护路径。</string>
|
||||
<string name="simple_checked_reachability">所选网站在当前网络上是否按预期工作。</string>
|
||||
|
||||
<string name="simple_verdict_hard_bypass">直接信号表明流量可以经过意外路径。</string>
|
||||
<string name="simple_verdict_address_conflict">多项地址观察存在冲突,表明流量可能被转发。</string>
|
||||
<string name="simple_verdict_location_conflict">连接位置与本地网络位置冲突。</string>
|
||||
<string name="simple_verdict_hosting_review">该地址类似托管或中转连接,但其他信号尚无定论。</string>
|
||||
<string name="simple_verdict_combined_signals">结论基于连接位置与设备网络信号的组合。</string>
|
||||
<string name="simple_verdict_review_signals">一个或多个辅助信号需要关注,但单独不足以证明问题。</string>
|
||||
<string name="simple_verdict_general">结论基于已完成检查的结构化结果。</string>
|
||||
|
||||
<string name="technical_data_title">技术数据</string>
|
||||
<string name="technical_data_expand_content_description">展开技术数据</string>
|
||||
<string name="technical_data_collapse_content_description">收起技术数据</string>
|
||||
<string name="technical_data_empty">此卡片没有记录技术条目。</string>
|
||||
<string name="technical_data_empty_body">未记录正文。</string>
|
||||
<string name="technical_data_source">来源:%1$s</string>
|
||||
<string name="technical_data_target">目标:%1$s</string>
|
||||
<string name="technical_data_status">状态:%1$s</string>
|
||||
<string name="technical_data_duration">耗时:%1$d 毫秒</string>
|
||||
<string name="technical_data_size">保存大小:%1$s</string>
|
||||
<string name="technical_data_truncated">已截断(原始大小:%1$s)</string>
|
||||
<string name="technical_data_run_truncated">已截断:本次检查已达到 512 KiB 总上限。</string>
|
||||
<string name="technical_data_bytes">%1$d 字节</string>
|
||||
<string name="technical_data_copy_block">复制区块</string>
|
||||
<string name="technical_data_copy_block_content_description">复制来自 %1$s 的技术数据</string>
|
||||
<string name="technical_data_copied">已复制技术数据</string>
|
||||
<string name="simple_cause_ip_ru_services_disagree">俄罗斯 IP 查询服务报告了不同的公网地址。</string>
|
||||
<string name="simple_cause_ip_non_ru_services_disagree">国际 IP 查询服务报告了不同的公网地址。</string>
|
||||
<string name="simple_cause_ip_groups_disagree">俄罗斯和国际服务报告了不同的公网地址。</string>
|
||||
<string name="simple_cause_ip_families_differ">服务返回了不同的地址类型,因此无法直接比较这些结果。</string>
|
||||
<string name="simple_cause_ip_partial_response">部分公网地址查询服务没有响应,因此比较结果不完整。</string>
|
||||
<string name="simple_cause_ip_unavailable">所有公网地址查询服务都没有返回可用结果。</string>
|
||||
<string name="simple_cause_cdn_responses_differ">受测网站通过不同的公网地址访问。</string>
|
||||
<string name="simple_cause_cdn_partial_response">部分受测网站没有返回足够数据,无法完成比较。</string>
|
||||
<string name="simple_cause_public_ip_location">公网地址的国家或网络归属信息指向 VPN、代理、托管网络或意外的国家。</string>
|
||||
<string name="simple_cause_vpn_network_state">Android 报告当前网络通过 VPN 传输。</string>
|
||||
<string name="simple_cause_active_vpn_app">某个应用当前正在维持 VPN 连接。</string>
|
||||
<string name="simple_cause_system_proxy">Android 已启用全局流量转发设置。</string>
|
||||
<string name="simple_cause_local_proxy">设备上的本地流量转发服务正在响应。</string>
|
||||
<string name="simple_cause_vpn_interface">用于隧道流量的网络接口处于活动状态。</string>
|
||||
<string name="simple_cause_vpn_route">设备路由表将流量发送到隧道或意外的网关。</string>
|
||||
<string name="simple_cause_dns_redirection">域名请求通过不同或重定向的网络路径处理。</string>
|
||||
<string name="simple_cause_traffic_bypass">VPN 外的流量使用了不同的网络路径或公网地址。</string>
|
||||
<string name="simple_cause_proxy_auth_required">本地代理要求身份验证,因此无法确认其用途。</string>
|
||||
<string name="simple_cause_location_conflict">SIM 卡、移动网络和公网地址位置数据描述了不同的路径。</string>
|
||||
<string name="simple_cause_icmp_response_mismatch">测试数据包收到的响应与发出的数据包不一致。</string>
|
||||
<string name="simple_cause_rtt_route_pattern">响应时间表明部分目标可能使用了不同的网络路径。</string>
|
||||
<string name="simple_cause_telegram_call_path">Telegram 通话连接检查发现了异常或不完整的网络路径。</string>
|
||||
<string name="simple_cause_whatsapp_call_path">WhatsApp 通话连接检查发现了异常或不完整的网络路径。</string>
|
||||
<string name="simple_cause_call_check_unavailable">通话连接服务没有返回足够数据来检查路径。</string>
|
||||
<string name="simple_cause_domain_dns_mismatch">网站名称的解析结果与预期不同。</string>
|
||||
<string name="simple_cause_domain_tcp_mismatch">与网站建立连接的结果与预期不同。</string>
|
||||
<string name="simple_cause_domain_tls_mismatch">安全连接的完成结果与预期不同。</string>
|
||||
<string name="simple_cause_public_data_unavailable">公网地址查询服务没有返回足够数据。</string>
|
||||
<string name="simple_cause_network_data_unavailable">Android 没有提供足够的网络状态数据来完成此检查。</string>
|
||||
<string name="simple_cause_remote_data_unavailable">远程测试服务没有返回足够数据来完成此检查。</string>
|
||||
<string name="simple_cause_device_data_unavailable">Android 没有提供足够的设备级网络数据来完成此检查。</string>
|
||||
<string name="simple_cause_device_environment">设备环境中存在可能影响网络检查的修改或限制。</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,16 @@
|
|||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_result_display_mode">Result details</string>
|
||||
<string name="settings_result_display_mode_simple">Simple</string>
|
||||
<string name="settings_result_display_mode_normal">Normal</string>
|
||||
<string name="settings_result_display_mode_advanced">Advanced</string>
|
||||
<string name="settings_result_display_mode_simple_desc">Plain-language summaries without technical details. Applies to the next check.</string>
|
||||
<string name="settings_result_display_mode_normal_desc">The standard result view and details. Applies to the next check.</string>
|
||||
<string name="settings_result_display_mode_advanced_desc">Standard results with expandable technical data. Applies to the next check.</string>
|
||||
<string name="settings_result_display_mode_simple_content_description">Simple result display mode</string>
|
||||
<string name="settings_result_display_mode_normal_content_description">Normal result display mode</string>
|
||||
<string name="settings_result_display_mode_advanced_content_description">Advanced result display mode</string>
|
||||
<string name="settings_language">Language</string>
|
||||
<string name="settings_language_system">System</string>
|
||||
<string name="settings_language_en">English</string>
|
||||
|
|
@ -577,7 +587,7 @@
|
|||
<string name="settings_value_network_all">All requests</string>
|
||||
<string name="settings_value_network_disabled">Disabled</string>
|
||||
<string name="settings_value_privacy_masking">IP masking</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
|
||||
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
|
||||
|
||||
<!-- NativeSignsChecker -->
|
||||
<string name="checker_native_category_name">Native signs β</string>
|
||||
|
|
@ -665,6 +675,65 @@
|
|||
<string name="checker_native_isolation_clone">App runs in a cloned/dual-app container (user %1$d); a VPN in the primary profile is not observable</string>
|
||||
<string name="checker_native_isolation_work_profile">App runs in a managed work profile; a VPN in the personal profile is not observable without root</string>
|
||||
|
||||
<!-- NativeSignsChecker: VPN signals -->
|
||||
<string name="checker_native_vpn_signal">VPN signal: %1$s</string>
|
||||
<string name="vpn_desc_prop">VPN system property detected</string>
|
||||
<string name="vpn_desc_vpnhide">VPN hide file found</string>
|
||||
<string name="vpn_desc_hook">Hook framework property detected</string>
|
||||
<string name="vpn_desc_dns">DNS property points to VPN</string>
|
||||
<string name="vpn_desc_file">VPN tool file found</string>
|
||||
<string name="vpn_desc_lsposed">LSPosed/Xposed environment detected</string>
|
||||
<string name="vpn_desc_tcp_port">TCP connection on VPN port</string>
|
||||
<string name="vpn_desc_udp_port">UDP socket on VPN port</string>
|
||||
<string name="vpn_desc_inet6">IPv6 VPN interface in /proc</string>
|
||||
<string name="vpn_desc_route">Route via VPN interface</string>
|
||||
<string name="vpn_desc_arp">ARP entry on VPN interface</string>
|
||||
<string name="vpn_desc_forwarding">IP forwarding is enabled</string>
|
||||
<string name="vpn_desc_rpfilter">Reverse path filter disabled</string>
|
||||
<string name="vpn_desc_established">Established connection to private IP on VPN port</string>
|
||||
<string name="vpn_desc_policy_rules">VPN routing rules found</string>
|
||||
<string name="vpn_desc_qdisc">VPN queue discipline detected</string>
|
||||
<string name="vpn_desc_hidden_mac">Hidden MAC addresses in neighbor table</string>
|
||||
<string name="vpn_desc_mss">Suspiciously low MSS (possible tunnel)</string>
|
||||
<string name="vpn_desc_bindtodevice">setsockopt intercepted (VPN hook)</string>
|
||||
<string name="vpn_desc_port_conflict">Port conflict on loopback (VPN listener)</string>
|
||||
<string name="vpn_desc_bpf">Access to netd BPF maps</string>
|
||||
<string name="vpn_desc_recverr">IP_RECVERR intercepted (VPN hook)</string>
|
||||
<string name="vpn_desc_sysfs_leak">VPN interface leaked via sysfs</string>
|
||||
<string name="vpn_desc_getifaddrs">VPN interface in getifaddrs()</string>
|
||||
<string name="vpn_desc_sysclassnet">VPN interface in /sys/class/net</string>
|
||||
<string name="vpn_desc_rtm_getlink">VPN interface in RTM_GETLINK</string>
|
||||
<string name="vpn_desc_proc_if_inet6">VPN IPv6 interface in /proc/net/if_inet6</string>
|
||||
<string name="vpn_desc_proc_ipv6_route">VPN IPv6 route in /proc/net/ipv6_route</string>
|
||||
<string name="vpn_desc_proc_net_dev">VPN traffic in /proc/net/dev</string>
|
||||
<string name="vpn_desc_fib_trie">/proc/net/fib_trie access denied (SELinux)</string>
|
||||
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE leaked VPN interface</string>
|
||||
<string name="vpn_desc_inet_diag">inet_diag netlink denied (SELinux)</string>
|
||||
<string name="vpn_desc_getsockname">getsockname() leaked VPN IP</string>
|
||||
<string name="vpn_desc_policy_rules_netlink">VPN policy rules via netlink</string>
|
||||
<string name="vpn_desc_route_count">Route count and anonymous interfaces</string>
|
||||
<string name="vpn_desc_udp_port_physical">UDP port conflict on physical interface</string>
|
||||
<string name="vpn_desc_trim_oracle">Bind probe vs RTM_GETLINK discrepancy</string>
|
||||
<string name="vpn_desc_ifindexname">VPN interface exposed via if_indextoname</string>
|
||||
<string name="vpn_desc_pmtu_mss">UDP PMTU and TCP MSS analysis</string>
|
||||
<string name="vpn_desc_udp_pmtu_ok">UDP send succeeded (no PMTU bottleneck)</string>
|
||||
<string name="vpn_desc_udp_pmtu_fail">UDP send failed (PMTU issue)</string>
|
||||
<string name="vpn_desc_normal_pmtu">Normal path MTU detection</string>
|
||||
<string name="vpn_desc_traceroute">Traceroute probe (ICMP/TTL)</string>
|
||||
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
|
||||
<string name="vpn_desc_backpressure">Backpressure under sustained burst</string>
|
||||
<string name="vpn_desc_gso_failed">GSO support unavailable (diagnostic only)</string>
|
||||
<string name="vpn_desc_gso_send_failed">GSO send failed (diagnostic only)</string>
|
||||
<string name="vpn_desc_gso_ok">GSO supported (normal interface)</string>
|
||||
<string name="vpn_desc_hw_timestamp">Hardware timestamping check</string>
|
||||
<string name="vpn_status_not_found">not found</string>
|
||||
<string name="vpn_status_unavailable">unavailable (no permission)</string>
|
||||
<string name="checker_vpn_detector_category_name">VPN Detector (deep)</string>
|
||||
<string name="checker_vpn_detector_unavailable">VPN detector library not loaded</string>
|
||||
<string name="checker_vpn_detector_unavailable_with_reason">VPN detector library not loaded: %1$s</string>
|
||||
<string name="checker_vpn_detector_no_anomalies">No deep VPN signs detected</string>
|
||||
|
||||
|
||||
<!-- App update checker -->
|
||||
<string name="update_dialog_title">Update available</string>
|
||||
<string name="update_dialog_message">Installed version: %1$s\nLatest version: %2$s</string>
|
||||
|
|
@ -965,4 +1034,111 @@
|
|||
<string name="export_duration_unit_ms">ms</string>
|
||||
<string name="export_available">available</string>
|
||||
<string name="export_unavailable">unavailable</string>
|
||||
<string name="simple_result_checked_label">What was checked</string>
|
||||
<string name="simple_result_outcome_label">Result</string>
|
||||
<string name="simple_result_explanation_label">What this means</string>
|
||||
<string name="simple_result_running">Checking now</string>
|
||||
<string name="simple_result_clean">No problems found</string>
|
||||
<string name="simple_result_review">There is some uncertainty</string>
|
||||
<string name="simple_result_detected">A problem was found</string>
|
||||
<string name="simple_result_error">Could not check</string>
|
||||
<string name="simple_result_disabled">This check is turned off</string>
|
||||
<string name="simple_result_explanation_running">The result will appear when this part of the check finishes.</string>
|
||||
<string name="simple_result_explanation_clean">The checked signs looked normal.</string>
|
||||
<string name="simple_result_explanation_error">This part did not return enough information. Other checks can still finish.</string>
|
||||
<string name="simple_result_explanation_review">The check found an unusual sign, but it is not enough on its own to confirm a problem.</string>
|
||||
<string name="simple_result_area_public_address">The unusual sign appeared in the public network address information.</string>
|
||||
<string name="simple_result_area_device_network">The unusual sign appeared in the device network state.</string>
|
||||
<string name="simple_result_area_local_app">The unusual sign was linked to an installed or active network app.</string>
|
||||
<string name="simple_result_area_local_proxy">The unusual sign appeared in a local traffic redirection service.</string>
|
||||
<string name="simple_result_area_network_route">The unusual sign appeared in the path used by network traffic.</string>
|
||||
<string name="simple_result_area_call_route">The unusual sign appeared in the network path used for calls.</string>
|
||||
<string name="simple_result_area_location">The unusual sign appeared when network and location information were compared.</string>
|
||||
<string name="simple_result_area_remote_site">The unusual sign appeared while contacting a remote service.</string>
|
||||
<string name="simple_result_area_device_environment">The unusual sign appeared in the device system environment.</string>
|
||||
<string name="simple_result_area_multiple">Related signs appeared in more than one part of the network check.</string>
|
||||
<string name="simple_result_extra_information">Additional information: this card does not change the final verdict by itself.</string>
|
||||
|
||||
<string name="simple_title_geo">Where the connection appears to be</string>
|
||||
<string name="simple_title_ip_comparison">Whether services see the same address</string>
|
||||
<string name="simple_title_cdn">How remote sites answer</string>
|
||||
<string name="simple_title_direct">Visible network settings</string>
|
||||
<string name="simple_title_indirect">Other network signs</string>
|
||||
<string name="simple_title_native">Device-level network signs</string>
|
||||
<string name="simple_title_call_transport">Call traffic path</string>
|
||||
<string name="simple_title_icmp">Network reply consistency</string>
|
||||
<string name="simple_title_rtt">Connection delay comparison</string>
|
||||
<string name="simple_title_location">Network and location match</string>
|
||||
<string name="simple_title_bypass">Traffic leaving by another path</string>
|
||||
<string name="simple_title_reachability">Whether selected sites are reachable</string>
|
||||
|
||||
<string name="simple_checked_geo">The country and network shown by the public connection address.</string>
|
||||
<string name="simple_checked_ip_comparison">Whether different services report a consistent public address.</string>
|
||||
<string name="simple_checked_cdn">Whether selected remote services return the expected information.</string>
|
||||
<string name="simple_checked_direct">Network settings that directly report traffic redirection.</string>
|
||||
<string name="simple_checked_indirect">Supporting signs in apps, interfaces and network configuration.</string>
|
||||
<string name="simple_checked_native">Device-level signs that are harder for apps to hide.</string>
|
||||
<string name="simple_checked_call_transport">Whether call traffic follows an unexpected network path.</string>
|
||||
<string name="simple_checked_icmp">Whether network replies look consistent with the contacted service.</string>
|
||||
<string name="simple_checked_rtt">Whether connection delays differ in an unusual way.</string>
|
||||
<string name="simple_checked_location">Whether local network and location information agree.</string>
|
||||
<string name="simple_checked_bypass">Whether traffic can leave outside the expected protected path.</string>
|
||||
<string name="simple_checked_reachability">Whether selected sites behave as expected on this network.</string>
|
||||
|
||||
<string name="simple_verdict_hard_bypass">A direct sign showed that traffic can use an unexpected path.</string>
|
||||
<string name="simple_verdict_address_conflict">Several address observations disagree in a way that points to traffic redirection.</string>
|
||||
<string name="simple_verdict_location_conflict">The connection location conflicts with the local network location.</string>
|
||||
<string name="simple_verdict_hosting_review">The address resembles a hosted or relayed connection, but the other signs are not conclusive.</string>
|
||||
<string name="simple_verdict_combined_signals">The verdict is based on the combined connection location and device network signs.</string>
|
||||
<string name="simple_verdict_review_signals">One or more supporting signs need attention, but they do not prove a problem by themselves.</string>
|
||||
<string name="simple_verdict_general">The verdict is based on the structured results of the completed checks.</string>
|
||||
|
||||
<string name="technical_data_title">Technical data</string>
|
||||
<string name="technical_data_expand_content_description">Expand technical data</string>
|
||||
<string name="technical_data_collapse_content_description">Collapse technical data</string>
|
||||
<string name="technical_data_empty">No technical entries were recorded for this card.</string>
|
||||
<string name="technical_data_empty_body">No body was recorded.</string>
|
||||
<string name="technical_data_source">Source: %1$s</string>
|
||||
<string name="technical_data_target">Target: %1$s</string>
|
||||
<string name="technical_data_status">Status: %1$s</string>
|
||||
<string name="technical_data_duration">Duration: %1$d ms</string>
|
||||
<string name="technical_data_size">Stored size: %1$s</string>
|
||||
<string name="technical_data_truncated">Truncated (original size: %1$s)</string>
|
||||
<string name="technical_data_run_truncated">Truncated: the scan-wide 512 KiB limit was reached.</string>
|
||||
<string name="technical_data_bytes">%1$d B</string>
|
||||
<string name="technical_data_copy_block">Copy block</string>
|
||||
<string name="technical_data_copy_block_content_description">Copy technical data from %1$s</string>
|
||||
<string name="technical_data_copied">Technical data copied</string>
|
||||
<string name="simple_cause_ip_ru_services_disagree">Russian IP services reported different public addresses.</string>
|
||||
<string name="simple_cause_ip_non_ru_services_disagree">International IP services reported different public addresses.</string>
|
||||
<string name="simple_cause_ip_groups_disagree">Russian and international services reported different public addresses.</string>
|
||||
<string name="simple_cause_ip_families_differ">The services returned different address families, so the answers cannot be compared directly.</string>
|
||||
<string name="simple_cause_ip_partial_response">Some public-address services did not answer, so the comparison is incomplete.</string>
|
||||
<string name="simple_cause_ip_unavailable">No public-address service returned a usable answer.</string>
|
||||
<string name="simple_cause_cdn_responses_differ">The tested sites were reached through different public addresses.</string>
|
||||
<string name="simple_cause_cdn_partial_response">Some tested sites did not return enough data for a complete comparison.</string>
|
||||
<string name="simple_cause_public_ip_location">Public-address location or network ownership data indicates a VPN, proxy, hosting network, or an unexpected country.</string>
|
||||
<string name="simple_cause_vpn_network_state">Android reports that the active network is carried through a VPN.</string>
|
||||
<string name="simple_cause_active_vpn_app">An application is currently maintaining the VPN connection.</string>
|
||||
<string name="simple_cause_system_proxy">Android has a system-wide traffic redirection setting enabled.</string>
|
||||
<string name="simple_cause_local_proxy">A local service that can redirect network traffic is responding on the device.</string>
|
||||
<string name="simple_cause_vpn_interface">A network interface created for tunneled traffic is active.</string>
|
||||
<string name="simple_cause_vpn_route">The device routing table sends traffic through a tunnel or an unexpected gateway.</string>
|
||||
<string name="simple_cause_dns_redirection">Domain-name requests are handled through a different or redirected network path.</string>
|
||||
<string name="simple_cause_traffic_bypass">Traffic sent outside the VPN used a different network path or public address.</string>
|
||||
<string name="simple_cause_proxy_auth_required">A local proxy asked for authentication, so its purpose could not be confirmed.</string>
|
||||
<string name="simple_cause_location_conflict">SIM, mobile network, and public-address location data do not describe the same route.</string>
|
||||
<string name="simple_cause_icmp_response_mismatch">A test packet received a response that did not match the packet that was sent.</string>
|
||||
<string name="simple_cause_rtt_route_pattern">Response times indicate that some destinations may be using a different network route.</string>
|
||||
<string name="simple_cause_telegram_call_path">The Telegram call connectivity check found an unusual or incomplete network path.</string>
|
||||
<string name="simple_cause_whatsapp_call_path">The WhatsApp call connectivity check found an unusual or incomplete network path.</string>
|
||||
<string name="simple_cause_call_check_unavailable">The call connectivity services did not return enough data to check the route.</string>
|
||||
<string name="simple_cause_domain_dns_mismatch">The domain name resolved differently from the expected result.</string>
|
||||
<string name="simple_cause_domain_tcp_mismatch">The connection to the service opened differently from the expected result.</string>
|
||||
<string name="simple_cause_domain_tls_mismatch">The secure connection completed differently from the expected result.</string>
|
||||
<string name="simple_cause_public_data_unavailable">The services used to determine the public address did not return enough data.</string>
|
||||
<string name="simple_cause_network_data_unavailable">Android did not provide enough network-state data to finish this check.</string>
|
||||
<string name="simple_cause_remote_data_unavailable">The remote test services did not return enough data to finish this check.</string>
|
||||
<string name="simple_cause_device_data_unavailable">Android did not provide enough device-level network data to finish this check.</string>
|
||||
<string name="simple_cause_device_environment">The device environment contains changes or restrictions that can alter network checks.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<Unit>()
|
||||
val capturedContexts = mutableListOf<ScanExecutionContext>()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Unit>(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.NORMAL)
|
||||
invokePrivate<Unit>(
|
||||
activity,
|
||||
"applyScanEvent",
|
||||
ScanEvent.Update(CheckUpdate.DirectSignsReady(technicalCategory())),
|
||||
false,
|
||||
)
|
||||
invokePrivate<Unit>(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<Unit>(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.SIMPLE)
|
||||
invokePrivate<Unit>(
|
||||
activity,
|
||||
"applyScanEvent",
|
||||
ScanEvent.Update(CheckUpdate.DirectSignsReady(technicalCategory())),
|
||||
false,
|
||||
)
|
||||
invokePrivate<Unit>(activity, "expandCategory", "dir")
|
||||
|
||||
assertEquals(View.VISIBLE, activity.findViewById<View>(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<Unit>(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<Unit>(
|
||||
activity,
|
||||
"applyScanEvent",
|
||||
ScanEvent.Update(
|
||||
CheckUpdate.IpComparisonReady(
|
||||
IpComparisonResult(
|
||||
detected = false,
|
||||
needsReview = true,
|
||||
summary = rawSummary,
|
||||
ruGroup = ruGroup,
|
||||
nonRuGroup = nonRuGroup,
|
||||
),
|
||||
),
|
||||
),
|
||||
false,
|
||||
)
|
||||
invokePrivate<Unit>(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<Unit>(
|
||||
activity,
|
||||
"applyScanEvent",
|
||||
ScanEvent.Completed(checkResult(snapshot(runTruncated = true)), false, ResultDisplayMode.ADVANCED),
|
||||
false,
|
||||
)
|
||||
invokePrivate<Unit>(activity, "expandCategory", "dir")
|
||||
val body = activity.findViewById<ViewGroup>(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<Unit>(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<Unit>(
|
||||
activity,
|
||||
"applyScanEvent",
|
||||
ScanEvent.Completed(checkResult(snapshot()), false, ResultDisplayMode.ADVANCED),
|
||||
false,
|
||||
)
|
||||
val body = activity.findViewById<ViewGroup>(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<CheckViewModel>(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 <T> 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 <T> 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Chip>(R.id.chipResultDisplayNormal)
|
||||
val simple = root.findViewById<Chip>(R.id.chipResultDisplaySimple)
|
||||
val advanced = root.findViewById<Chip>(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<TextView>(R.id.textResultDisplayModeDescription).text.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accessibility fragment saves color vision mode`() {
|
||||
val activity = Robolectric.buildActivity(SettingsActivity::class.java).setup().get()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Finding> = 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -501,7 +501,7 @@ For every interface, `/sys/class/net/<name>/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/<if>")` |
|
||||
| `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
|
||||
|
|
|
|||
|
|
@ -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/<if>")` |
|
||||
| `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 و شناسه سلول ذخیره نمیشود.
|
||||
|
||||
---
|
||||
|
||||
## قدردانی
|
||||
|
|
|
|||
|
|
@ -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/<if>")` |
|
||||
| `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 或蜂窝标识符。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
|
|
|||
5
fastlane/metadata/android/en-US/changelogs/21000.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/21000.txt
Normal file
|
|
@ -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.
|
||||
5
fastlane/metadata/android/fa/changelogs/21000.txt
Normal file
5
fastlane/metadata/android/fa/changelogs/21000.txt
Normal file
|
|
@ -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 و نمایش نتایج فنی و قابلفهم بررسی بهبود یافت.
|
||||
5
fastlane/metadata/android/ru-RU/changelogs/21000.txt
Normal file
5
fastlane/metadata/android/ru-RU/changelogs/21000.txt
Normal file
|
|
@ -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-адресов и отображение подробных и понятных результатов проверки.
|
||||
5
fastlane/metadata/android/zh-CN/changelogs/21000.txt
Normal file
5
fastlane/metadata/android/zh-CN/changelogs/21000.txt
Normal file
|
|
@ -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 地址处理,以及详细结果和易懂结果的显示。
|
||||
Loading…
Add table
Add a link
Reference in a new issue