From 26fc550b7f2d94214e6161390f358fdf0d45bbfa Mon Sep 17 00:00:00 2001 From: Vadim Date: Thu, 9 Apr 2026 14:24:47 +0300 Subject: [PATCH] Merge pull request #11 from wecand0/fdroid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit внедрение detection engine и модульной архитектуры детектирования VPN --- app/build.gradle.kts | 28 +- app/src/main/AndroidManifest.xml | 30 +- app/src/main/cpp/CMakeLists.txt | 19 - app/src/main/cpp/ifconfigdetector.cpp | 263 ----------- .../cherepavel/vpndetector/MainActivity.kt | 184 ++++---- .../detector/IfconfigInterfaceInfo.kt | 64 --- .../detector/IfconfigTermuxLikeDetector.kt | 32 -- .../detector/TrackedAppsDetector.kt | 51 --- .../vpndetector/detector/VpnDetector.kt | 63 --- .../vpndetector/model/VpnDetectionResult.kt | 9 - .../vpndetector/model/VpnNetworkInfo.kt | 8 - .../vpndetector/service/VpnMonitorService.kt | 166 +++++++ .../vpndetector/ui/ReportExportFormatter.kt | 124 +++++- .../vpndetector/ui/ReportFormatter.kt | 306 +++++++++---- build.gradle.kts | 6 +- detector/build.gradle.kts | 39 ++ detector/src/main/AndroidManifest.xml | 6 + detector/src/main/cpp/.clang-format | 14 + detector/src/main/cpp/CMakeLists.txt | 40 ++ detector/src/main/cpp/ifconfigdetector.cpp | 410 ++++++++++++++++++ detector/src/main/cpp/run_clang_format.sh | 16 + .../detector/AlwaysOnVpnDetector.kt | 36 ++ .../vpndetector/detector/DetectionEngine.kt | 168 +++++++ .../vpndetector/detector/DetectionScorer.kt | 140 ++++++ .../vpndetector/detector/DetectionSignals.kt | 19 + .../detector/DynamicVpnAppsDetector.kt | 59 +++ .../vpndetector/detector/IDetectionEngine.kt | 7 + .../detector/IfconfigTermuxLikeDetector.kt | 77 ++++ .../detector/IfconfigTermuxLikeResult.kt | 3 +- .../detector/JavaInterfacesDetector.kt | 0 .../detector/KnownVpnDnsDetector.kt | 33 ++ .../detector/TrackedAppsDetector.kt | 54 +++ .../detector/TrackedAppsRepository.kt | 90 ++++ .../vpndetector/detector/TunnelNameMatcher.kt | 7 +- .../detector/VpnPermissionDetector.kt | 23 + .../detector/WorkProfileDetector.kt | 41 ++ .../vpndetector/model/DetectionModels.kt | 75 ++++ .../vpndetector/model/TrackedApp.kt | 0 .../cherepavel/vpndetector/util/Extensions.kt | 0 .../vpndetector/util/NetworkSignalAnalyzer.kt | 131 ++++++ .../util/TransportInfoFormatter.kt | 20 +- gradle.properties | 10 +- gradle/libs.versions.toml | 15 +- gradlew | 0 metadata/com.cherepavel.vpndetector.yml | 40 ++ settings.gradle.kts | 17 +- tracked_apps.json | 25 ++ 47 files changed, 2241 insertions(+), 727 deletions(-) delete mode 100644 app/src/main/cpp/CMakeLists.txt delete mode 100644 app/src/main/cpp/ifconfigdetector.cpp delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt delete mode 100644 app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt create mode 100644 app/src/main/java/com/cherepavel/vpndetector/service/VpnMonitorService.kt create mode 100644 detector/build.gradle.kts create mode 100644 detector/src/main/AndroidManifest.xml create mode 100644 detector/src/main/cpp/.clang-format create mode 100644 detector/src/main/cpp/CMakeLists.txt create mode 100644 detector/src/main/cpp/ifconfigdetector.cpp create mode 100755 detector/src/main/cpp/run_clang_format.sh create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/AlwaysOnVpnDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionEngine.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionScorer.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionSignals.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/DynamicVpnAppsDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/IDetectionEngine.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt (67%) rename {app => detector}/src/main/java/com/cherepavel/vpndetector/detector/JavaInterfacesDetector.kt (100%) create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/KnownVpnDnsDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsRepository.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt (86%) create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/VpnPermissionDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/detector/WorkProfileDetector.kt create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/model/DetectionModels.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/model/TrackedApp.kt (100%) rename {app => detector}/src/main/java/com/cherepavel/vpndetector/util/Extensions.kt (100%) create mode 100644 detector/src/main/java/com/cherepavel/vpndetector/util/NetworkSignalAnalyzer.kt rename {app => detector}/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt (52%) mode change 100644 => 100755 gradlew create mode 100644 metadata/com.cherepavel.vpndetector.yml create mode 100644 tracked_apps.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9cd4dba..f95b8ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,9 +2,8 @@ plugins { id("com.android.application") } -android { +extensions.configure { namespace = "com.cherepavel.vpndetector" - compileSdk = 36 defaultConfig { @@ -15,27 +14,12 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - externalNativeBuild { - cmake { - cppFlags += "" - } - } - } - - externalNativeBuild { - cmake { - path = file("src/main/cpp/CMakeLists.txt") - } } buildTypes { release { isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) + isShrinkResources = false } } @@ -45,12 +29,20 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} + dependencies { + api(project(":detector")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d44fd7e..ebe8a65 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,18 +1,19 @@ - + + + + + - - - - - - - - - - + + + - \ No newline at end of file + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt deleted file mode 100644 index ee2de6a..0000000 --- a/app/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,19 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) - -project("ifconfigdetector") - -add_library( - ifconfigdetector - SHARED - ifconfigdetector.cpp -) - -find_library( - log-lib - log -) - -target_link_libraries( - ifconfigdetector - ${log-lib} -) diff --git a/app/src/main/cpp/ifconfigdetector.cpp b/app/src/main/cpp/ifconfigdetector.cpp deleted file mode 100644 index 6f2d45e..0000000 --- a/app/src/main/cpp/ifconfigdetector.cpp +++ /dev/null @@ -1,263 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -struct AddressEntry { - int family = 0; - std::string address; - std::string netmask; - std::string peerOrBroadcast; - bool isPointToPoint = false; - bool isBroadcast = false; -}; - -struct InterfaceDump { - std::string name; - unsigned int flags = 0; - std::vector addresses; -}; - -static std::string sockaddrToString(const sockaddr* sa) { - if (!sa) return ""; - - char buf[INET6_ADDRSTRLEN] = {0}; - - if (sa->sa_family == AF_INET) { - const sockaddr_in* sin = reinterpret_cast(sa); - if (inet_ntop(AF_INET, &(sin->sin_addr), buf, sizeof(buf))) { - return std::string(buf); - } - } else if (sa->sa_family == AF_INET6) { - const sockaddr_in6* sin6 = reinterpret_cast(sa); - if (inet_ntop(AF_INET6, &(sin6->sin6_addr), buf, sizeof(buf))) { - return std::string(buf); - } - } - - return ""; -} - -static std::string readFirstLine(const std::string& path) { - std::ifstream file(path); - if (!file.is_open()) return ""; - - std::string line; - std::getline(file, line); - return line; -} - -static int readIntFromFile(const std::string& path, int fallback = -1) { - std::ifstream file(path); - if (!file.is_open()) return fallback; - - int value = fallback; - file >> value; - return file.fail() ? fallback : value; -} - -static std::string formatFlagNames(unsigned int flags) { - std::vector parts; - - if (flags & IFF_UP) parts.emplace_back("UP"); - if (flags & IFF_BROADCAST) parts.emplace_back("BROADCAST"); - if (flags & IFF_DEBUG) parts.emplace_back("DEBUG"); - if (flags & IFF_LOOPBACK) parts.emplace_back("LOOPBACK"); - if (flags & IFF_POINTOPOINT) parts.emplace_back("POINTOPOINT"); - if (flags & IFF_RUNNING) parts.emplace_back("RUNNING"); - if (flags & IFF_NOARP) parts.emplace_back("NOARP"); - if (flags & IFF_PROMISC) parts.emplace_back("PROMISC"); - if (flags & IFF_ALLMULTI) parts.emplace_back("ALLMULTI"); - if (flags & IFF_MULTICAST) parts.emplace_back("MULTICAST"); - - std::ostringstream oss; - for (size_t i = 0; i < parts.size(); ++i) { - if (i > 0) oss << ","; - oss << parts[i]; - } - return oss.str(); -} - -static int ipv6PrefixLenFromMask(const sockaddr* sa) { - if (!sa || sa->sa_family != AF_INET6) return -1; - - const sockaddr_in6* sin6 = reinterpret_cast(sa); - int bits = 0; - - for (int i = 0; i < 16; ++i) { - unsigned char byte = sin6->sin6_addr.s6_addr[i]; - for (int bit = 7; bit >= 0; --bit) { - if (byte & (1u << bit)) { - bits++; - } - } - } - - return bits; -} - -static bool addressEntryLess(const AddressEntry& a, const AddressEntry& b) { - if (a.family != b.family) { - return a.family == AF_INET; - } - return a.address < b.address; -} - -static std::string buildIfconfigLikeBlock(const InterfaceDump& iface, const std::map& mtuMap, const std::map& txQueueMap) { - std::ostringstream oss; - - const auto mtuIt = mtuMap.find(iface.name); - const auto txIt = txQueueMap.find(iface.name); - - const int mtu = (mtuIt != mtuMap.end()) ? mtuIt->second : -1; - const int txq = (txIt != txQueueMap.end()) ? txIt->second : -1; - - oss << iface.name << ": flags=" << iface.flags - << "<" << formatFlagNames(iface.flags) << ">"; - - if (mtu >= 0) { - oss << " mtu " << mtu; - } - - oss << "\n"; - - std::vector sorted = iface.addresses; - std::sort(sorted.begin(), sorted.end(), addressEntryLess); - - for (const auto& addr : sorted) { - if (addr.family == AF_INET) { - oss << " inet " << (addr.address.empty() ? "-" : addr.address); - - if (!addr.netmask.empty()) { - oss << " netmask " << addr.netmask; - } - - if (!addr.peerOrBroadcast.empty()) { - if (addr.isPointToPoint) { - oss << " destination " << addr.peerOrBroadcast; - } else if (addr.isBroadcast) { - oss << " broadcast " << addr.peerOrBroadcast; - } - } - - oss << "\n"; - } else if (addr.family == AF_INET6) { - oss << " inet6 " << (addr.address.empty() ? "-" : addr.address); - - if (!addr.netmask.empty()) { - oss << " prefixlen " << addr.netmask; - } - - if (!addr.peerOrBroadcast.empty() && addr.isPointToPoint) { - oss << " destination " << addr.peerOrBroadcast; - } - - oss << "\n"; - } - } - - if (txq >= 0) { - oss << " txqueuelen " << txq << "\n"; - } - - return oss.str(); -} - -extern "C" -JNIEXPORT jobjectArray JNICALL -Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getInterfacesNative( - JNIEnv* env, - jobject /* thiz */) { - - jclass stringCls = env->FindClass("java/lang/String"); - if (stringCls == nullptr) { - return nullptr; - } - - std::map interfaces; - std::map mtuMap; - std::map txQueueMap; - - struct ifaddrs* ifaddr = nullptr; - if (getifaddrs(&ifaddr) == -1 || ifaddr == nullptr) { - return env->NewObjectArray(0, stringCls, nullptr); - } - - for (struct ifaddrs* it = ifaddr; it != nullptr; it = it->ifa_next) { - if (!it->ifa_name) continue; - - std::string name(it->ifa_name); - auto& iface = interfaces[name]; - - iface.name = name; - iface.flags |= static_cast(it->ifa_flags); - - if (mtuMap.find(name) == mtuMap.end()) { - mtuMap[name] = readIntFromFile("/sys/class/net/" + name + "/mtu", -1); - } - if (txQueueMap.find(name) == txQueueMap.end()) { - txQueueMap[name] = readIntFromFile("/sys/class/net/" + name + "/tx_queue_len", -1); - } - - if (!it->ifa_addr) continue; - - const int family = it->ifa_addr->sa_family; - if (family != AF_INET && family != AF_INET6) continue; - - AddressEntry entry; - entry.family = family; - entry.address = sockaddrToString(it->ifa_addr); - entry.isPointToPoint = (it->ifa_flags & IFF_POINTOPOINT) != 0; - entry.isBroadcast = (it->ifa_flags & IFF_BROADCAST) != 0; - - if (family == AF_INET) { - entry.netmask = sockaddrToString(it->ifa_netmask); - } else if (family == AF_INET6) { - int prefixLen = ipv6PrefixLenFromMask(it->ifa_netmask); - if (prefixLen >= 0) { - entry.netmask = std::to_string(prefixLen); - } - } - - if (entry.isPointToPoint && it->ifa_dstaddr) { - entry.peerOrBroadcast = sockaddrToString(it->ifa_dstaddr); - } else if (entry.isBroadcast && it->ifa_ifu.ifu_broadaddr) { - entry.peerOrBroadcast = sockaddrToString(it->ifa_ifu.ifu_broadaddr); - } - - iface.addresses.push_back(entry); - } - - freeifaddrs(ifaddr); - - std::vector dumps; - dumps.reserve(interfaces.size()); - - for (const auto& pair : interfaces) { - dumps.push_back(buildIfconfigLikeBlock(pair.second, mtuMap, txQueueMap)); - } - - jobjectArray result = env->NewObjectArray( - static_cast(dumps.size()), - stringCls, - nullptr - ); - - for (jsize i = 0; i < static_cast(dumps.size()); ++i) { - jstring text = env->NewStringUTF(dumps[i].c_str()); - env->SetObjectArrayElement(result, i, text); - env->DeleteLocalRef(text); - } - - return result; -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt b/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt index 293a8e1..5334839 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt +++ b/app/src/main/java/com/cherepavel/vpndetector/MainActivity.kt @@ -1,11 +1,12 @@ package com.cherepavel.vpndetector -import android.content.Context +import android.annotation.SuppressLint import android.content.Intent import android.net.ConnectivityManager import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.net.Uri import android.os.Bundle import android.view.MotionEvent @@ -21,17 +22,22 @@ import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.widget.NestedScrollView -import com.cherepavel.vpndetector.detector.IfconfigTermuxLikeDetector -import com.cherepavel.vpndetector.detector.JavaInterfacesDetector -import com.cherepavel.vpndetector.detector.TrackedAppsDetector -import com.cherepavel.vpndetector.detector.TunnelNameMatcher +import androidx.lifecycle.lifecycleScope +import com.cherepavel.vpndetector.detector.DetectionEngine +import com.cherepavel.vpndetector.detector.IDetectionEngine +import com.cherepavel.vpndetector.detector.TrackedAppsRepository +import com.cherepavel.vpndetector.model.DetectionSnapshot import com.cherepavel.vpndetector.ui.DetectionReport import com.cherepavel.vpndetector.ui.ReportExportFormatter import com.cherepavel.vpndetector.ui.ReportFormatter import com.cherepavel.vpndetector.ui.SignalItem import com.cherepavel.vpndetector.ui.SignalState -import com.cherepavel.vpndetector.util.TransportInfoFormatter import com.cherepavel.vpndetector.util.nowString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.OutputStreamWriter class MainActivity : AppCompatActivity() { @@ -83,14 +89,31 @@ class MainActivity : AppCompatActivity() { private lateinit var apiSignalValues: List private lateinit var apiSignalHints: List - private val javaInterfacesDetector by lazy { JavaInterfacesDetector() } - private val trackedAppsDetector by lazy { TrackedAppsDetector(this) } + private val connectivityManager by lazy { + getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + } + private val detectionEngine: IDetectionEngine by lazy { DetectionEngine(this, connectivityManager) } - private var lastDetectionReport: DetectionReport? = null - private var lastNativeDetailsRaw: String = "" - private var lastJavaTunnelNames: List = emptyList() - private var lastInstalledVpnApps: List = emptyList() private var lastExportText: String = "" + private var detectionJob: Job? = null + private var scheduledRefreshJob: Job? = null + private var networkCallbackRegistered = false + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = scheduleRefresh() + + override fun onLost(network: Network) = scheduleRefresh() + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) = scheduleRefresh() + + override fun onLinkPropertiesChanged( + network: Network, + linkProperties: LinkProperties + ) = scheduleRefresh() + } private val createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> @@ -117,9 +140,18 @@ class MainActivity : AppCompatActivity() { bindViews() setupListeners() + registerNetworkCallback() + lifecycleScope.launch(Dispatchers.IO) { TrackedAppsRepository.refresh(applicationContext) } refreshUi() } + override fun onDestroy() { + unregisterNetworkCallback() + detectionJob?.cancel() + scheduledRefreshJob?.cancel() + super.onDestroy() + } + private fun bindViews() { cardStatus = findViewById(R.id.cardStatus) textVpnStatus = findViewById(R.id.textVpnStatus) @@ -169,6 +201,7 @@ class MainActivity : AppCompatActivity() { apiSignalHints = listOf(textApiSignalHint1, textApiSignalHint2) } + @SuppressLint("ClickableViewAccessibility") private fun setupListeners() { buttonRefresh.setOnClickListener { refreshUi() } @@ -192,71 +225,67 @@ class MainActivity : AppCompatActivity() { } } + private data class DetectionOutput( + val report: DetectionReport, + val snapshot: DetectionSnapshot, + val exportText: String + ) + private fun refreshUi() { - val connectivityManager = - getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + detectionJob?.cancel() + buttonRefresh.isEnabled = false + buttonReport.isEnabled = false + detectionJob = lifecycleScope.launch { + val output = withContext(Dispatchers.IO) { runDetection() } + renderReport(output.report) + renderLastUpdate() + lastExportText = output.exportText + buttonRefresh.isEnabled = true + buttonReport.isEnabled = true + } + } - val allNetworks = connectivityManager.allNetworks.orEmpty() - val activeNetwork = connectivityManager.activeNetwork + private fun scheduleRefresh() { + scheduledRefreshJob?.cancel() + scheduledRefreshJob = lifecycleScope.launch { + delay(250) + refreshUi() + } + } - val vpnNetworks = allNetworks.filter { hasTransportVpn(connectivityManager, it) } - val anyVpn = vpnNetworks.isNotEmpty() - val activeVpn = activeNetwork?.let { hasTransportVpn(connectivityManager, it) } ?: false + private fun registerNetworkCallback() { + if (networkCallbackRegistered) return + val request = NetworkRequest.Builder().build() + runCatching { + connectivityManager.registerNetworkCallback(request, networkCallback) + }.onSuccess { + networkCallbackRegistered = true + } + } - val preferredNetwork = vpnNetworks.firstOrNull() ?: activeNetwork ?: allNetworks.firstOrNull() + private fun unregisterNetworkCallback() { + if (!networkCallbackRegistered) return + runCatching { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + networkCallbackRegistered = false + } - val preferredLinkProperties: LinkProperties? = - preferredNetwork?.let(connectivityManager::getLinkProperties) + private fun runDetection(): DetectionOutput { + val snapshot = detectionEngine.detect() + val report = ReportFormatter.build(snapshot) - val preferredCapabilities: NetworkCapabilities? = - preferredNetwork?.let(connectivityManager::getNetworkCapabilities) - - val interfaceName = preferredLinkProperties - ?.interfaceName - ?.takeIf { TunnelNameMatcher.looksLikeTunnelName(it) } - - val transportInfoSummary = - TransportInfoFormatter.summarizeVpnTransportInfo(preferredCapabilities) - - val nativeResult = IfconfigTermuxLikeDetector.detect() - val javaTunnelNames = javaInterfacesDetector.detectTunnelNames() - - val installedVpnApps = trackedAppsDetector.detect() - .map { "${it.label} (${it.packageName})" } - - val nativeTunnelNames = nativeResult.matchedInterfaces - .map { it.substringBefore(':').trim() } - .distinct() - - val nativeDetails = nativeResult.allInterfaces - - val report = ReportFormatter.build( - ReportFormatter.RawInput( - hasTransportVpnAny = anyVpn, - hasTransportVpnActive = activeVpn, - interfaceName = interfaceName, - transportInfoSummary = transportInfoSummary, - nativeTunnelNames = nativeTunnelNames, - nativeDetails = nativeDetails, - javaTunnelNames = javaTunnelNames, - installedVpnApps = installedVpnApps + val exportText = ReportExportFormatter.buildText( + ReportExportFormatter.ExportInput( + report = report, + snapshot = snapshot ) ) - renderReport(report) - renderLastUpdate() - - lastDetectionReport = report - lastNativeDetailsRaw = report.nativeDetails - lastJavaTunnelNames = javaTunnelNames - lastInstalledVpnApps = installedVpnApps - lastExportText = ReportExportFormatter.buildText( - ReportExportFormatter.ExportInput( - report = report, - nativeDetailsRaw = report.nativeDetails, - javaTunnelNames = javaTunnelNames, - installedVpnApps = installedVpnApps - ) + return DetectionOutput( + report = report, + snapshot = snapshot, + exportText = exportText ) } @@ -266,7 +295,7 @@ class MainActivity : AppCompatActivity() { textVpnExplanation.text = report.overallExplanation applySectionCardBackground(cardStatus) - applyStatusTextColor(textVpnStatus, report.overallState) + applyValueTextColor(textVpnStatus, report.overallState) textTransportState.text = report.transportStateText textTransportSubtitle.text = report.transportSubtitle @@ -278,11 +307,11 @@ class MainActivity : AppCompatActivity() { applyTransportBadgeBackground( view = textTransportAnyValue, - isDetected = report.transportAnyValue == "DETECTED" + isDetected = report.transportAnyDetected ) applyTransportBadgeBackground( view = textTransportActiveValue, - isDetected = report.transportActiveValue == "DETECTED" + isDetected = report.transportActiveDetected ) renderApiSignals(report.apiSignals) @@ -345,14 +374,7 @@ class MainActivity : AppCompatActivity() { applyValueTextColor(valueView, item.state) } - private fun hasTransportVpn( - connectivityManager: ConnectivityManager, - network: Network - ): Boolean { - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - } - + @SuppressLint("SetTextI18n") private fun renderLastUpdate() { textLastUpdate.text = "Last update: ${nowString()}" } @@ -417,10 +439,6 @@ class MainActivity : AppCompatActivity() { view.setTextColor(state.toSignalColor()) } - private fun applyStatusTextColor(view: TextView, state: SignalState) { - view.setTextColor(state.toSignalColor()) - } - private fun applyTransportBadgeBackground(view: TextView, isDetected: Boolean) { view.setBackgroundResource( if (isDetected) { diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt deleted file mode 100644 index d17955a..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigInterfaceInfo.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.cherepavel.vpndetector.detector - -data class IfconfigInterfaceInfo( - val name: String?, - val flags: String?, - val address: String?, - val netmask: String?, - val broadcast: String?, - val isUp: Boolean -) { - fun normalizedName(): String = name?.trim().orEmpty() - - fun hasUsableAddress(): Boolean { - val value = address?.trim().orEmpty() - return value.isNotEmpty() && value != "-" && value != "null" - } - - fun isLoopbackLike(): Boolean { - val lowered = normalizedName().lowercase() - return lowered == "lo" || lowered.startsWith("lo") - } - - fun isPointToPointLike(): Boolean { - val loweredFlags = flags?.lowercase().orEmpty() - return loweredFlags.contains("pointopoint") || - loweredFlags.contains("point-to-point") - } - - fun looksLikeTunnel(): Boolean { - val normalized = normalizedName() - return TunnelNameMatcher.looksLikeTunnelName(normalized) || - (!isLoopbackLike() && hasUsableAddress() && isPointToPointLike()) - } - - fun toDisplayBlock(): String { - val displayName = normalizedName().ifBlank { "unknown" } - val displayFlags = flags?.takeIf { it.isNotBlank() } ?: "" - val displayAddress = address ?: "-" - val displayNetmask = netmask ?: "-" - val displayBroadcast = broadcast ?: "-" - val upDown = if (isUp) "UP" else "DOWN" - - return buildString { - append(displayName) - append(" ") - append(upDown) - - if (displayFlags.isNotBlank()) { - append(" flags=") - append(displayFlags) - } - - append("\n") - append(" addr=") - append(displayAddress) - append("\n") - append(" mask=") - append(displayNetmask) - append("\n") - append(" broadcast=") - append(displayBroadcast) - } - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt deleted file mode 100644 index 6fab0cd..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.cherepavel.vpndetector.detector - -object IfconfigTermuxLikeDetector { - - init { - try { - System.loadLibrary("ifconfigdetector") - } catch (_: Throwable) { - } - } - - external fun getInterfacesNative(): Array - - fun detect(): IfconfigTermuxLikeResult { - val allBlocks = try { - getInterfacesNative().toList() - } catch (_: Throwable) { - emptyList() - } - - val matched = allBlocks.filter { block -> - val firstLine = block.lineSequence().firstOrNull().orEmpty() - TunnelNameMatcher.looksLikeTunnelName(firstLine.substringBefore(':').trim()) - } - - return IfconfigTermuxLikeResult( - vpnLikely = matched.isNotEmpty(), - matchedInterfaces = matched, - allInterfaces = allBlocks - ) - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt deleted file mode 100644 index ce42713..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.cherepavel.vpndetector.detector - -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import com.cherepavel.vpndetector.model.TrackedApp - -class TrackedAppsDetector( - private val context: Context -) { - fun detect(): List { - return TRACKED_APPS.filter { isAppInstalled(it.packageName) } - } - - private fun isAppInstalled(packageName: String): Boolean { - return try { - val pm = context.packageManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - pm.getPackageInfo(packageName, 0) - } - true - } catch (_: Throwable) { - false - } - } - - companion object { - private val TRACKED_APPS = listOf( - TrackedApp("com.github.dyhkwong.sagernet", "ExclaveVPN"), - TrackedApp("com.v2ray.ang", "v2rayNG"), - TrackedApp("org.amnezia.awg", "AmneziaWG"), - TrackedApp("org.amnezia.vpn", "Amnezia VPN"), - TrackedApp("de.blinkt.openvpn", "OpenVPN for Android"), - TrackedApp("net.openvpn.openvpn", "OpenVPN Connect"), - TrackedApp("com.wireguard.android", "WireGuard"), - TrackedApp("com.cloudflare.onedotonedotonedotone", "Cloudflare WARP"), - TrackedApp("com.psiphon3", "Psiphon"), - TrackedApp("app.hiddify.com", "Hiddify"), - TrackedApp("io.nekohasekai.sfa", "SFA"), - TrackedApp("com.nordvpn.android", "NordVPN"), - TrackedApp("com.expressvpn.vpn", "ExpressVPN"), - TrackedApp("com.protonvpn.android", "Proton VPN"), - TrackedApp("free.vpn.unblock.proxy.turbovpn", "Turbo VPN"), - TrackedApp("com.zaneschepke.wireguardautotunnel", "WG Tunnel"), - TrackedApp("moe.nb4a", "NekoBox") - ) - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt b/app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt deleted file mode 100644 index d14a142..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/VpnDetector.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.cherepavel.vpndetector.detector - -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import com.cherepavel.vpndetector.model.VpnDetectionResult -import com.cherepavel.vpndetector.model.VpnNetworkInfo -import com.cherepavel.vpndetector.util.TransportInfoFormatter - -class VpnDetector( - private val context: Context -) { - fun detect(): VpnDetectionResult { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val activeNetwork = cm.activeNetwork - val activeCaps = activeNetwork?.let(cm::getNetworkCapabilities) - - val allNetworks = cm.allNetworks.toList() - - val vpnNetworks = allNetworks.mapNotNull { network -> - val caps = cm.getNetworkCapabilities(network) ?: return@mapNotNull null - if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@mapNotNull null - - val linkProps = cm.getLinkProperties(network) - - VpnNetworkInfo( - interfaceName = linkProps?.interfaceName, - transports = extractTransports(caps), - capabilities = extractCapabilities(caps), - transportInfoSummary = TransportInfoFormatter.summarizeVpnTransportInfo(caps) - ) - } - - return VpnDetectionResult( - activeNetworkPresent = activeNetwork != null, - activeNetworkIsVpn = activeCaps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN), - anyNetworkHasVpnTransport = vpnNetworks.isNotEmpty(), - activeNetworkHasInternet = activeCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true, - vpnNetworks = vpnNetworks - ) - } - - private fun extractTransports(caps: NetworkCapabilities): List { - return buildList { - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("WIFI") - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("CELLULAR") - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("ETHERNET") - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) add("BLUETOOTH") - if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN") - } - } - - private fun extractCapabilities(caps: NetworkCapabilities): List { - return buildList { - if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) add("INTERNET") - if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) add("VALIDATED") - if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)) add("TRUSTED") - if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)) add("NOT_RESTRICTED") - if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) add("NOT_VPN") - } - } -} diff --git a/app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt b/app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt deleted file mode 100644 index d823f15..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/model/VpnDetectionResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.cherepavel.vpndetector.model - -data class VpnDetectionResult( - val activeNetworkPresent: Boolean, - val activeNetworkIsVpn: Boolean?, - val anyNetworkHasVpnTransport: Boolean, - val activeNetworkHasInternet: Boolean, - val vpnNetworks: List -) diff --git a/app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt b/app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt deleted file mode 100644 index 2ff5ecf..0000000 --- a/app/src/main/java/com/cherepavel/vpndetector/model/VpnNetworkInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.cherepavel.vpndetector.model - -data class VpnNetworkInfo( - val interfaceName: String?, - val transports: List, - val capabilities: List, - val transportInfoSummary: String? -) diff --git a/app/src/main/java/com/cherepavel/vpndetector/service/VpnMonitorService.kt b/app/src/main/java/com/cherepavel/vpndetector/service/VpnMonitorService.kt new file mode 100644 index 0000000..b2fdea1 --- /dev/null +++ b/app/src/main/java/com/cherepavel/vpndetector/service/VpnMonitorService.kt @@ -0,0 +1,166 @@ +package com.cherepavel.vpndetector.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.cherepavel.vpndetector.MainActivity +import com.cherepavel.vpndetector.R +import com.cherepavel.vpndetector.detector.DetectionEngine +import com.cherepavel.vpndetector.detector.IDetectionEngine +import com.cherepavel.vpndetector.model.DetectionSnapshot +import com.cherepavel.vpndetector.model.DetectionStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class VpnMonitorService : Service() { + + private val connectivityManager by lazy { + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + private val detectionEngine: IDetectionEngine by lazy { DetectionEngine(this) } + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private var networkCallbackRegistered = false + private var debounceJob: Job? = null + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = scheduleDetection() + override fun onLost(network: Network) = scheduleDetection() + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) = scheduleDetection() + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIFICATION_ID, buildNotification("Monitoring VPN state…")) + registerNetworkCallback() + scheduleDetection() + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + unregisterNetworkCallback() + scope.cancel() + super.onDestroy() + } + + private fun scheduleDetection() { + debounceJob?.cancel() + debounceJob = scope.launch { + delay(DEBOUNCE_MS) + val snapshot = withContext(Dispatchers.IO) { detectionEngine.detect() } + updateNotification(snapshot) + } + } + + private fun updateNotification(snapshot: DetectionSnapshot) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(snapshot.toStatusText())) + } + + private fun DetectionSnapshot.toStatusText(): String = when (assessment.status) { + DetectionStatus.ACTIVE_VPN -> + if (lockdownLikely) "VPN active — lockdown mode" else "VPN active" + DetectionStatus.SPLIT_TUNNEL -> + "VPN present (split tunnel / bypass)" + DetectionStatus.VPN_LIKE -> + "VPN-like signals detected (score ${assessment.score}/100)" + DetectionStatus.APPS_PRESENT -> + "No active VPN — VPN apps installed" + DetectionStatus.NO_EVIDENCE -> + "No VPN detected" + } + + private fun buildNotification(text: String): Notification { + val pendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("VPN Detector") + .setContentText(text) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setShowWhen(false) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "VPN Monitor", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Live VPN detection status" + setShowBadge(false) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun registerNetworkCallback() { + if (networkCallbackRegistered) return + runCatching { + connectivityManager.registerNetworkCallback( + NetworkRequest.Builder().build(), + networkCallback + ) + }.onSuccess { networkCallbackRegistered = true } + } + + private fun unregisterNetworkCallback() { + if (!networkCallbackRegistered) return + runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) } + networkCallbackRegistered = false + } + + companion object { + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "vpn_monitor_channel" + private const val DEBOUNCE_MS = 300L + + fun start(context: Context) { + val intent = Intent(context, VpnMonitorService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, VpnMonitorService::class.java)) + } + } +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt index 20dd68b..9a5629c 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportExportFormatter.kt @@ -1,18 +1,18 @@ package com.cherepavel.vpndetector.ui +import com.cherepavel.vpndetector.model.DetectionSnapshot import com.cherepavel.vpndetector.util.nowString object ReportExportFormatter { data class ExportInput( val report: DetectionReport, - val nativeDetailsRaw: String, - val javaTunnelNames: List, - val installedVpnApps: List + val snapshot: DetectionSnapshot ) fun buildText(input: ExportInput): String { val report = input.report + val snapshot = input.snapshot return buildString { appendLine("VPN Detector Report") @@ -23,6 +23,9 @@ object ReportExportFormatter { appendLine(report.overallTitle) appendLine(report.overallSummary) appendLine(report.overallExplanation) + appendLine("Score: ${snapshot.assessment.score}/100") + appendLine("Confidence: ${snapshot.assessment.confidence}") + appendLine("Status: ${snapshot.assessment.status}") appendLine() appendLine("=== OFFICIAL ANDROID API ===") @@ -43,27 +46,128 @@ object ReportExportFormatter { appendLine() } + if (snapshot.vpnRoutes.isNotEmpty() || snapshot.vpnDnsServers.isNotEmpty() || + snapshot.allDnsServers.isNotEmpty() || snapshot.internalDnsServers.isNotEmpty() || + snapshot.contextualInternalDnsServers.isNotEmpty() || + snapshot.privateDnsActive || snapshot.privateDnsServerName != null || + snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null || + snapshot.vpnBandwidthSummary != null) { + appendLine("=== VPN NETWORK DETAILS ===") + if (snapshot.vpnRoutes.isNotEmpty()) { + appendLine("Routes:") + snapshot.vpnRoutes.forEach { appendLine(" $it") } + } + if (snapshot.vpnDnsServers.isNotEmpty()) { + appendLine("DNS servers: ${snapshot.vpnDnsServers.joinToString(", ")}") + } + if (snapshot.allDnsServers.isNotEmpty()) { + appendLine("DNS across visible networks:") + snapshot.allDnsServers.forEach { appendLine(" $it") } + } + if (snapshot.internalDnsServers.isNotEmpty()) { + appendLine("Internal/private-range DNS servers:") + snapshot.internalDnsServers.forEach { appendLine(" $it") } + } + if (snapshot.contextualInternalDnsServers.isNotEmpty()) { + appendLine("Cellular private DNS observed (not treated as VPN):") + snapshot.contextualInternalDnsServers.forEach { appendLine(" $it") } + } + if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) { + appendLine( + "Private DNS: " + buildString { + append(if (snapshot.privateDnsActive) "active" else "inactive") + snapshot.privateDnsServerName?.let { append(" ($it)") } + } + ) + } + if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) { + appendLine( + "NET_CAPABILITY_NOT_VPN: active=${snapshot.activeNetworkNotVpn ?: "unknown"}, " + + "preferred=${snapshot.preferredNetworkNotVpn ?: "unknown"}" + ) + } + snapshot.vpnBandwidthSummary?.let { appendLine("Bandwidth: $it") } + appendLine() + } + + if (snapshot.tunTypeInterfaces.isNotEmpty() || snapshot.lowMtuInterfaces.isNotEmpty() || + snapshot.kernelRoutes.isNotEmpty() || snapshot.kernelIpv6Routes.isNotEmpty() || + snapshot.vpnPermissionGranted) { + appendLine("=== ADDITIONAL SIGNALS ===") + if (snapshot.tunTypeInterfaces.isNotEmpty()) { + appendLine("TUN interfaces (type=65534): ${snapshot.tunTypeInterfaces.joinToString(", ")}") + } + if (snapshot.lowMtuInterfaces.isNotEmpty()) { + appendLine("Low-MTU interfaces (<1500):") + snapshot.lowMtuInterfaces.forEach { appendLine(" $it") } + } + if (snapshot.kernelRoutes.isNotEmpty()) { + appendLine("Kernel route table (/proc/net/route):") + snapshot.kernelRoutes.forEach { appendLine(" $it") } + } + if (snapshot.kernelIpv6Routes.isNotEmpty()) { + appendLine("Kernel route table (/proc/net/ipv6_route):") + snapshot.kernelIpv6Routes.forEach { appendLine(" $it") } + } + if (snapshot.vpnPermissionGranted) { + appendLine("VPN permission: this app holds VPN grant (anomalous).") + } + appendLine() + } + appendLine("=== NATIVE LOW-LEVEL ENUMERATION ===") + if (snapshot.nativeError != null) { + appendLine("Error: ${snapshot.nativeError}") + } appendLine("Signal value: ${report.nativeSignal.value}") appendLine("Signal hint: ${report.nativeSignal.hint}") appendLine() - appendLine(input.nativeDetailsRaw) + appendLine(report.nativeDetails) appendLine() appendLine("=== JAVA INTERFACE ENUMERATION ===") appendLine("Signal value: ${report.javaSignal.value}") appendLine("Signal hint: ${report.javaSignal.hint}") - if (input.javaTunnelNames.isNotEmpty()) { + if (snapshot.javaTunnelNames.isNotEmpty()) { appendLine("Matched tunnel-like names:") - input.javaTunnelNames.forEach { appendLine("- $it") } + snapshot.javaTunnelNames.forEach { appendLine("- $it") } } appendLine() appendLine("=== DETECTED VPN APPS ===") - if (input.installedVpnApps.isEmpty()) { - appendLine("No known VPN-related apps from the tracked list are installed.") - } else { - input.installedVpnApps.forEach { appendLine("- $it") } + if (snapshot.installedVpnApps.isNotEmpty()) { + appendLine("From tracked list:") + snapshot.installedVpnApps.forEach { appendLine("- $it") } + } + if (snapshot.unknownDynamicApps.isNotEmpty()) { + appendLine("Detected via VpnService query:") + snapshot.unknownDynamicApps.forEach { appendLine("- $it") } + } + if (snapshot.trackedAppsErrors.isNotEmpty()) { + appendLine("Check errors:") + snapshot.trackedAppsErrors.forEach { (pkg, err) -> appendLine("- $pkg: $err") } + } + if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) { + appendLine("No VPN-related apps detected.") + } + + if (snapshot.lockdownLikely || snapshot.knownVpnDnsMatches.isNotEmpty() || + snapshot.workProfileCount > 1 || snapshot.isManagedProfile) { + appendLine() + appendLine("=== ADVANCED SIGNALS ===") + if (snapshot.lockdownLikely) { + appendLine("Always-on lockdown: likely (no validated non-VPN path exists).") + } + if (snapshot.knownVpnDnsMatches.isNotEmpty()) { + appendLine("Known VPN provider DNS:") + snapshot.knownVpnDnsMatches.forEach { appendLine("- $it") } + } + if (snapshot.workProfileCount > 1) { + appendLine("Work profile: ${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible.") + } + if (snapshot.isManagedProfile) { + appendLine("Running inside a managed profile.") + } } }.trim() } diff --git a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt index 9c30ea2..80189ef 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt +++ b/app/src/main/java/com/cherepavel/vpndetector/ui/ReportFormatter.kt @@ -1,6 +1,9 @@ package com.cherepavel.vpndetector.ui import com.cherepavel.vpndetector.detector.TunnelNameMatcher +import com.cherepavel.vpndetector.model.DetectionConfidence +import com.cherepavel.vpndetector.model.DetectionSnapshot +import com.cherepavel.vpndetector.model.DetectionStatus data class DetectionReport( val overallTitle: String, @@ -13,6 +16,8 @@ data class DetectionReport( val transportSubtitle: String, val transportAnyValue: String, val transportActiveValue: String, + val transportAnyDetected: Boolean, + val transportActiveDetected: Boolean, val apiSignals: List, val nativeSignal: SignalItem, @@ -23,42 +28,44 @@ data class DetectionReport( object ReportFormatter { - data class RawInput( - val hasTransportVpnAny: Boolean, - val hasTransportVpnActive: Boolean, - val interfaceName: String?, - val transportInfoSummary: String?, - val nativeTunnelNames: List, - val nativeDetails: List, - val javaTunnelNames: List, - val installedVpnApps: List - ) + fun build(snapshot: DetectionSnapshot): DetectionReport { + val anyVpn = snapshot.hasTransportVpnAny + val activeVpn = snapshot.hasTransportVpnActive - fun build(input: RawInput): DetectionReport { - val anyVpn = input.hasTransportVpnAny - val activeVpn = input.hasTransportVpnActive - - val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(input.interfaceName) - val transportInfoDetected = !input.transportInfoSummary.isNullOrBlank() - val nativeDetected = input.nativeTunnelNames.isNotEmpty() - val javaDetected = input.javaTunnelNames.isNotEmpty() - val appsDetected = input.installedVpnApps.isNotEmpty() + val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(snapshot.rawInterfaceName) + val transportInfoDetected = !snapshot.transportInfoSummary.isNullOrBlank() + val dnsDetected = snapshot.internalDnsServers.isNotEmpty() + val policyDetected = + snapshot.activeNetworkNotVpn == false || snapshot.preferredNetworkNotVpn == false + val nativeDetected = snapshot.nativeTunnelNames.isNotEmpty() + val javaDetected = snapshot.javaTunnelNames.isNotEmpty() + val appsDetected = snapshot.installedVpnApps.isNotEmpty() val overall = buildOverallBlock( + snapshot = snapshot, activeVpn = activeVpn, anyVpn = anyVpn, interfaceDetected = interfaceDetected, transportInfoDetected = transportInfoDetected, + dnsDetected = dnsDetected, + policyDetected = policyDetected, nativeDetected = nativeDetected, javaDetected = javaDetected, appsDetected = appsDetected ) val apiSignals = buildApiSignals( - interfaceName = input.interfaceName, + rawInterfaceName = snapshot.rawInterfaceName, interfaceDetected = interfaceDetected, - transportInfoSummary = input.transportInfoSummary, + transportInfoSummary = snapshot.transportInfoSummary, transportInfoDetected = transportInfoDetected, + allDnsServers = snapshot.allDnsServers, + internalDnsServers = snapshot.internalDnsServers, + contextualInternalDnsServers = snapshot.contextualInternalDnsServers, + privateDnsActive = snapshot.privateDnsActive, + privateDnsServerName = snapshot.privateDnsServerName, + activeNetworkNotVpn = snapshot.activeNetworkNotVpn, + preferredNetworkNotVpn = snapshot.preferredNetworkNotVpn, activeVpn = activeVpn, anyVpn = anyVpn ) @@ -66,7 +73,7 @@ object ReportFormatter { val nativeSignal = SignalItem( title = "Tunnel-like interfaces", source = "Native getifaddrs() enumeration", - value = input.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", "), + value = snapshot.nativeTunnelNames.ifEmpty { listOf("none") }.joinToString(", "), state = if (nativeDetected) SignalState.WARNING else SignalState.NEGATIVE, hint = if (nativeDetected) { "Native enumeration found interfaces whose names or properties look tunnel-like." @@ -78,7 +85,7 @@ object ReportFormatter { val javaSignal = SignalItem( title = "Tunnel-like interfaces", source = "Java NetworkInterface enumeration", - value = input.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", "), + value = snapshot.javaTunnelNames.ifEmpty { listOf("none") }.joinToString(", "), state = if (javaDetected) SignalState.WARNING else SignalState.NEGATIVE, hint = if (javaDetected) { "Java network enumeration found interface names that look like VPN or tunnel interfaces." @@ -87,17 +94,112 @@ object ReportFormatter { } ) - val nativeDetailsText = if (input.nativeDetails.isEmpty()) { - "No interfaces were returned by the native detector." - } else { - input.nativeDetails.joinToString(separator = "\n\n") + val nativeDetailsText = buildString { + if (snapshot.nativeError != null) { + appendLine("Native detector error: ${snapshot.nativeError}") + appendLine() + } + if (snapshot.nativeDetails.isNotEmpty()) { + append(snapshot.nativeDetails.joinToString(separator = "\n\n")) + } else if (snapshot.nativeError == null) { + append("No interfaces were returned by the native detector.") + } + if (snapshot.tunTypeInterfaces.isNotEmpty()) { + append("\n\n--- TUN interfaces (type=65534) ---\n") + append(snapshot.tunTypeInterfaces.joinToString(", ")) + } + if (snapshot.lowMtuInterfaces.isNotEmpty()) { + append("\n\n--- Low-MTU interfaces (<1500) ---\n") + append(snapshot.lowMtuInterfaces.joinToString("\n")) + } + if (snapshot.vpnRoutes.isNotEmpty()) { + append("\n\n--- VPN network routes ---\n") + append(snapshot.vpnRoutes.joinToString("\n")) + } + if (snapshot.vpnDnsServers.isNotEmpty()) { + append("\n\n--- VPN DNS servers ---\n") + append(snapshot.vpnDnsServers.joinToString(", ")) + } + if (snapshot.allDnsServers.isNotEmpty()) { + append("\n\n--- DNS servers across visible networks ---\n") + append(snapshot.allDnsServers.joinToString("\n")) + } + if (snapshot.internalDnsServers.isNotEmpty()) { + append("\n\n--- Internal/private-range DNS servers ---\n") + append(snapshot.internalDnsServers.joinToString("\n")) + } + if (snapshot.contextualInternalDnsServers.isNotEmpty()) { + append("\n\n--- Cellular private DNS observed (not treated as VPN) ---\n") + append(snapshot.contextualInternalDnsServers.joinToString("\n")) + } + if (snapshot.privateDnsActive || snapshot.privateDnsServerName != null) { + append("\n\n--- Private DNS ---\n") + append( + buildString { + append(if (snapshot.privateDnsActive) "active" else "inactive") + snapshot.privateDnsServerName?.let { append(" ($it)") } + } + ) + } + if (snapshot.activeNetworkNotVpn != null || snapshot.preferredNetworkNotVpn != null) { + append("\n\n--- NET_CAPABILITY_NOT_VPN ---\n") + append("active=") + append(snapshot.activeNetworkNotVpn?.toString() ?: "unknown") + append(", preferred=") + append(snapshot.preferredNetworkNotVpn?.toString() ?: "unknown") + } + if (snapshot.vpnBandwidthSummary != null) { + append("\n\n--- VPN bandwidth ---\n") + append(snapshot.vpnBandwidthSummary) + } + if (snapshot.kernelRoutes.isNotEmpty()) { + append("\n\n--- Kernel route table (/proc/net/route) ---\n") + append(snapshot.kernelRoutes.joinToString("\n")) + } + if (snapshot.kernelIpv6Routes.isNotEmpty()) { + append("\n\n--- Kernel route table (/proc/net/ipv6_route) ---\n") + append(snapshot.kernelIpv6Routes.joinToString("\n")) + } + if (snapshot.vpnPermissionGranted) { + append("\n\n--- VPN permission ---\n") + append("This app holds Android VPN permission (anomalous for a detector).") + } + if (snapshot.lockdownLikely) { + append("\n\n--- Always-on / lockdown ---\n") + append("VPN present and no validated non-VPN path exists. Lockdown mode is likely active.") + } + if (snapshot.knownVpnDnsMatches.isNotEmpty()) { + append("\n\n--- Known VPN provider DNS ---\n") + append(snapshot.knownVpnDnsMatches.joinToString("\n")) + } + if (snapshot.workProfileCount > 1 || snapshot.isManagedProfile) { + append("\n\n--- Work / managed profile ---\n") + if (snapshot.isManagedProfile) { + append("Running inside a managed profile.\n") + } + if (snapshot.workProfileCount > 1) { + append("${snapshot.workProfileCount} user profiles detected. VPN apps in other profiles are not visible to this detector.") + } + } } - val appsText = if (input.installedVpnApps.isEmpty()) { - "No known VPN-related apps from the tracked list are installed." - } else { - input.installedVpnApps.joinToString(separator = "\n") { "• $it" } - } + val appsText = buildString { + if (snapshot.installedVpnApps.isEmpty() && snapshot.unknownDynamicApps.isEmpty()) { + append("No VPN-related apps detected.") + } else { + snapshot.installedVpnApps.forEach { appendLine("• $it") } + if (snapshot.unknownDynamicApps.isNotEmpty()) { + if (snapshot.installedVpnApps.isNotEmpty()) appendLine() + appendLine("Detected via VpnService query:") + snapshot.unknownDynamicApps.forEach { appendLine("• $it") } + } + } + if (snapshot.trackedAppsErrors.isNotEmpty()) { + if (snapshot.installedVpnApps.isNotEmpty() || snapshot.unknownDynamicApps.isNotEmpty()) appendLine() + appendLine("Check errors (package manager returned unexpected error):") + snapshot.trackedAppsErrors.forEach { (pkg, err) -> appendLine("• $pkg: $err") } + } + }.trimEnd() return DetectionReport( overallTitle = overall.title, @@ -110,6 +212,8 @@ object ReportFormatter { transportSubtitle = overall.transportSubtitle, transportAnyValue = if (anyVpn) "DETECTED" else "NOT DETECTED", transportActiveValue = if (activeVpn) "DETECTED" else "NOT DETECTED", + transportAnyDetected = anyVpn, + transportActiveDetected = activeVpn, apiSignals = apiSignals, nativeSignal = nativeSignal, @@ -120,20 +224,26 @@ object ReportFormatter { } private fun buildOverallBlock( + snapshot: DetectionSnapshot, activeVpn: Boolean, anyVpn: Boolean, interfaceDetected: Boolean, transportInfoDetected: Boolean, + dnsDetected: Boolean, + policyDetected: Boolean, nativeDetected: Boolean, javaDetected: Boolean, appsDetected: Boolean ): OverallBlock { + val confidenceText = snapshot.assessment.confidence.label() + val scoreText = "Confidence: $confidenceText (${snapshot.assessment.score}/100)." return when { - activeVpn -> { + snapshot.assessment.status == DetectionStatus.ACTIVE_VPN || activeVpn -> { + val lockdownNote = if (snapshot.lockdownLikely) " Lockdown mode appears active — no non-VPN path is validated." else "" OverallBlock( - title = "VPN detected", - summary = "The active network is explicitly marked as VPN by Android.", - explanation = "This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use.", + title = if (snapshot.lockdownLikely) "VPN detected (lockdown)" else "VPN detected", + summary = "The active network is explicitly marked as VPN by Android.$lockdownNote", + explanation = "This is the strongest signal in the app: Android reports TRANSPORT_VPN on the network currently in use. $scoreText", state = SignalState.POSITIVE, transportState = SignalState.POSITIVE, transportText = "VPN DETECTED", @@ -141,11 +251,11 @@ object ReportFormatter { ) } - anyVpn -> { + snapshot.assessment.status == DetectionStatus.SPLIT_TUNNEL || anyVpn -> { OverallBlock( title = "VPN present outside active path", summary = "Android sees a VPN network in the system, but not on the current active network.", - explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it.", + explanation = "This often matches bypass or split-tunnel behavior: a VPN exists, but current traffic may not be fully routed through it. $scoreText", state = SignalState.SEMI, transportState = SignalState.SEMI, transportText = "SPLIT / BYPASS", @@ -153,11 +263,11 @@ object ReportFormatter { ) } - interfaceDetected || transportInfoDetected -> { + interfaceDetected || transportInfoDetected || dnsDetected || policyDetected -> { OverallBlock( title = "VPN-related API signal", summary = "Android APIs still expose VPN-like indicators even though active TRANSPORT_VPN is absent.", - explanation = "This is weaker than a direct VPN transport flag, but it still suggests that VPN-related state may be visible through official APIs.", + explanation = "This is weaker than a direct VPN transport flag, but interface, DNS, or capability signals still suggest VPN-related state in the visible network stack. $scoreText", state = SignalState.WARNING, transportState = SignalState.WARNING, transportText = "API SIGNAL", @@ -169,7 +279,7 @@ object ReportFormatter { OverallBlock( title = "Low-level tunnel signal", summary = "No primary Android VPN signal was found, but tunnel-like interfaces were still discovered.", - explanation = "This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals.", + explanation = "This usually means only low-level interface heuristics fired. It is useful as an additional hint, but weaker than official Android VPN signals. $scoreText", state = SignalState.WARNING, transportState = SignalState.NEGATIVE, transportText = "NOT DETECTED", @@ -177,11 +287,11 @@ object ReportFormatter { ) } - appsDetected -> { + snapshot.assessment.status == DetectionStatus.APPS_PRESENT || appsDetected -> { OverallBlock( title = "Detected VPN apps", summary = "No active VPN network signal was found, but known VPN-related apps are installed on the device.", - explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal.", + explanation = "Installed VPN apps do not prove that a VPN is currently active, but they are still a relevant contextual signal. $scoreText", state = SignalState.WARNING, transportState = SignalState.NEGATIVE, transportText = "NOT DETECTED", @@ -193,7 +303,7 @@ object ReportFormatter { OverallBlock( title = "No VPN detected", summary = "The app did not find any high-level or low-level VPN indicators.", - explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal.", + explanation = "Neither official Android network APIs nor interface enumeration produced a VPN-related signal. $scoreText", state = SignalState.NEGATIVE, transportState = SignalState.NEGATIVE, transportText = "NOT DETECTED", @@ -204,61 +314,98 @@ object ReportFormatter { } private fun buildApiSignals( - interfaceName: String?, + rawInterfaceName: String?, interfaceDetected: Boolean, transportInfoSummary: String?, transportInfoDetected: Boolean, + allDnsServers: List, + internalDnsServers: List, + contextualInternalDnsServers: List, + privateDnsActive: Boolean, + privateDnsServerName: String?, + activeNetworkNotVpn: Boolean?, + preferredNetworkNotVpn: Boolean?, activeVpn: Boolean, anyVpn: Boolean ): List { + val interfaceTransportDetected = interfaceDetected || transportInfoDetected val interfaceState = when { - interfaceDetected && (activeVpn || anyVpn) -> SignalState.POSITIVE - interfaceDetected -> SignalState.WARNING + interfaceTransportDetected && activeVpn -> SignalState.POSITIVE + interfaceTransportDetected && anyVpn -> SignalState.SEMI + interfaceTransportDetected -> SignalState.WARNING else -> SignalState.NEGATIVE } - val transportInfoState = when { - transportInfoDetected && (activeVpn || anyVpn) -> SignalState.POSITIVE - transportInfoDetected -> SignalState.WARNING + val dnsPolicyDetected = + internalDnsServers.isNotEmpty() || activeNetworkNotVpn == false || preferredNetworkNotVpn == false + val dnsState = when { + internalDnsServers.isNotEmpty() && (activeVpn || anyVpn) -> SignalState.POSITIVE + dnsPolicyDetected -> SignalState.WARNING + privateDnsActive -> SignalState.NEUTRAL else -> SignalState.NEGATIVE } val interfaceHint = when { - interfaceDetected && activeVpn -> - "The interface name itself looks like a tunnel device and matches the active VPN state." - interfaceDetected && anyVpn -> - "The interface name looks tunnel-like and is consistent with a VPN being present somewhere in the system." - interfaceDetected -> - "The interface name looks tunnel-like, but Android does not currently mark the active path as VPN." + interfaceTransportDetected && activeVpn -> + "Interface naming or transport metadata aligns with an active VPN reported by Android." + interfaceTransportDetected && anyVpn -> + "Interface naming or transport metadata aligns with a VPN that exists somewhere in the system." + interfaceTransportDetected -> + "Interface naming or transport metadata looks VPN-like, but Android does not currently mark the active path as VPN." + rawInterfaceName != null -> + "Android returned interface '$rawInterfaceName' and no VPN-like transport metadata was exposed." else -> - "The interface name does not look like a typical VPN or tunnel interface." + "Android returned no interface or transport metadata for this network." } - val transportInfoHint = when { - transportInfoDetected && activeVpn -> - "Android returned transport info alongside an active VPN transport." - transportInfoDetected && anyVpn -> - "Transport info is present and aligns with a VPN existing somewhere in the network stack." - transportInfoDetected -> - "Transport info is present, but without a direct active VPN transport flag." + val dnsHint = when { + internalDnsServers.isNotEmpty() && (activeVpn || anyVpn) -> + "DNS points at internal/private ranges and matches the VPN-related network state." + internalDnsServers.isNotEmpty() -> + "DNS points at internal/private ranges often used by VPN clients, but Android did not expose TRANSPORT_VPN." + contextualInternalDnsServers.isNotEmpty() -> + "Carrier/private DNS was observed on a cellular interface and is shown as context only, not as a VPN signal." + activeNetworkNotVpn == false || preferredNetworkNotVpn == false -> + "At least one inspected network is missing NET_CAPABILITY_NOT_VPN, which is unusual outside VPN-managed paths." + privateDnsActive -> + "Private DNS is enabled. This is informational on its own, but useful when correlating DNS leak behavior." else -> - "No VPN-related transport info was exposed here." + "No suspicious DNS range or NOT_VPN capability anomaly was exposed here." } + val interfaceValue = listOfNotNull( + rawInterfaceName, + transportInfoSummary?.let { "transport: $it" } + ).ifEmpty { listOf("none") }.joinToString("\n") + + val dnsValue = buildList { + when { + internalDnsServers.isNotEmpty() -> + add(internalDnsServers.map(::stripIfacePrefix).joinToString("\n")) + contextualInternalDnsServers.isNotEmpty() -> + add(contextualInternalDnsServers.map(::stripIfacePrefix).joinToString("\n") + "\n(cellular)") + allDnsServers.isNotEmpty() -> + add(allDnsServers.map(::stripIfacePrefix).joinToString("\n")) + } + if (activeNetworkNotVpn == false) add("NOT_VPN cleared (active)") + if (preferredNetworkNotVpn == false) add("NOT_VPN cleared (preferred)") + if (privateDnsActive) add("DoH: ${privateDnsServerName ?: "on"}") + }.ifEmpty { listOf("none") }.joinToString("\n") + return listOf( SignalItem( - title = "Interface name", - source = "LinkProperties.getInterfaceName()", - value = interfaceName ?: "none", + title = "Interface / transport", + source = "LinkProperties + NetworkCapabilities", + value = interfaceValue, state = interfaceState, hint = interfaceHint ), SignalItem( - title = "Transport info", - source = "NetworkCapabilities.getTransportInfo()", - value = transportInfoSummary ?: "none", - state = transportInfoState, - hint = transportInfoHint + title = "DNS / policy", + source = "LinkProperties + NetworkCapabilities", + value = dnsValue, + state = dnsState, + hint = dnsHint ) ) } @@ -272,4 +419,17 @@ object ReportFormatter { val transportText: String, val transportSubtitle: String ) + + /** "wlan0:10.0.2.3" → "10.0.2.3", plain IPs pass through unchanged. */ + private fun stripIfacePrefix(s: String): String = + if (':' in s) s.substringAfter(':') else s + + private fun DetectionConfidence.label(): String { + return when (this) { + DetectionConfidence.CONFIRMED -> "confirmed" + DetectionConfidence.LIKELY -> "likely" + DetectionConfidence.WEAK_SIGNAL -> "weak signal" + DetectionConfidence.NO_EVIDENCE -> "no evidence" + } + } } diff --git a/build.gradle.kts b/build.gradle.kts index 3756278..4e7beab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,6 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. +// Top-level build file plugins { - alias(libs.plugins.android.application) apply false + id("com.android.application") apply false + id("com.android.library") apply false + id("org.jetbrains.kotlin.android") apply false } \ No newline at end of file diff --git a/detector/build.gradle.kts b/detector/build.gradle.kts new file mode 100644 index 0000000..d65dd01 --- /dev/null +++ b/detector/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id("com.android.library") +} + +extensions.configure { + namespace = "com.cherepavel.vpndetector.detector" + compileSdk = 36 + ndkVersion = "27.2.12479018" + + defaultConfig { + minSdk = 24 + + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) +} \ No newline at end of file diff --git a/detector/src/main/AndroidManifest.xml b/detector/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cbb96c1 --- /dev/null +++ b/detector/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/detector/src/main/cpp/.clang-format b/detector/src/main/cpp/.clang-format new file mode 100644 index 0000000..7741c60 --- /dev/null +++ b/detector/src/main/cpp/.clang-format @@ -0,0 +1,14 @@ +Language: Cpp +BasedOnStyle: LLVM +ColumnLimit: 120 +CompactNamespaces: true +AccessModifierOffset: -4 +ContinuationIndentWidth: 4 +IndentWidth: 4 +SpacesBeforeTrailingComments: 2 +MaxEmptyLinesToKeep: 1 +Standard: Latest +TabWidth: 4 +UseTab: Never +Cpp11BracedListStyle: true +AlwaysBreakTemplateDeclarations: true \ No newline at end of file diff --git a/detector/src/main/cpp/CMakeLists.txt b/detector/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..dce3d7a --- /dev/null +++ b/detector/src/main/cpp/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(ifconfigdetector LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build." FORCE) +endif() + +add_library(${PROJECT_NAME} + SHARED + ifconfigdetector.cpp +) + +#for tests in Linux +if(NOT ANDROID) + find_package(JNI REQUIRED) + target_include_directories(${PROJECT_NAME} PRIVATE ${JNI_INCLUDE_DIRS}) +endif () + +if(ANDROID) + find_library(log-lib log) + if(log-lib) + target_link_libraries(${PROJECT_NAME} PRIVATE ${log-lib}) + endif() + target_link_options(${PROJECT_NAME} PRIVATE -Wl,-z,max-page-size=16384) +endif() + +target_compile_options(${PROJECT_NAME} PRIVATE + -Wall -Wextra -fPIC + $<$:-O3 -DNDEBUG> + $<$:-g -O0> +) + +set_target_properties(${PROJECT_NAME} PROPERTIES + POSITION_INDEPENDENT_CODE ON + OUTPUT_NAME ${PROJECT_NAME} +) \ No newline at end of file diff --git a/detector/src/main/cpp/ifconfigdetector.cpp b/detector/src/main/cpp/ifconfigdetector.cpp new file mode 100644 index 0000000..fa4d315 --- /dev/null +++ b/detector/src/main/cpp/ifconfigdetector.cpp @@ -0,0 +1,410 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +struct AddressEntry { + int family = 0; + std::string address; + std::string netmask; + std::string peerOrBroadcast; + bool isPointToPoint = false; + bool isBroadcast = false; + + explicit AddressEntry(const int fam = 0, std::string addr = {}, std::string mask = {}, std::string peer = {}, + const bool p2p = false, const bool bc = false) + : family(fam), address(std::move(addr)), netmask(std::move(mask)), peerOrBroadcast(std::move(peer)), + isPointToPoint(p2p), isBroadcast(bc) {} +}; + +struct InterfaceDump { + std::string name; + unsigned int flags = 0; + std::vector addresses; +}; + +struct IfAddrsGuard { + ::ifaddrs *ptr = nullptr; + + ~IfAddrsGuard() { + if (ptr) + ::freeifaddrs(ptr); + } + + // Использование: getifaddrs(ifaddr()) + ::ifaddrs **operator()() noexcept { return &ptr; } + + [[nodiscard]] ::ifaddrs *get() const noexcept { return ptr; } + explicit operator ::ifaddrs *() const noexcept { return ptr; } +}; + +std::string sockaddrToString(const sockaddr *sa) { + if (!sa) + return {}; + + char buf[INET6_ADDRSTRLEN] = {}; + if (sa->sa_family == AF_INET) { + const auto *sin = reinterpret_cast(sa); + if (inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf))) + return buf; + } else if (sa->sa_family == AF_INET6) { + const auto *sin6 = reinterpret_cast(sa); + if (inet_ntop(AF_INET6, &sin6->sin6_addr, buf, sizeof(buf))) + return buf; + } + return {}; +} + +int readIntFromFile(const std::string &path, const int fallback = -1) { + std::ifstream file(path); + if (!file.is_open()) + return fallback; + + int value = fallback; + file >> value; + return file.fail() ? fallback : value; +} + +std::string formatFlagNames(const unsigned int flags) { + std::string result; + const auto append = [&](const char *name) { + if (!result.empty()) + result += ','; + result += name; + }; + + if (flags & IFF_UP) + append("UP"); + if (flags & IFF_BROADCAST) + append("BROADCAST"); + if (flags & IFF_DEBUG) + append("DEBUG"); + if (flags & IFF_LOOPBACK) + append("LOOPBACK"); + if (flags & IFF_POINTOPOINT) + append("POINTOPOINT"); + if (flags & IFF_RUNNING) + append("RUNNING"); + if (flags & IFF_NOARP) + append("NOARP"); + if (flags & IFF_PROMISC) + append("PROMISC"); + if (flags & IFF_ALLMULTI) + append("ALLMULTI"); + if (flags & IFF_MULTICAST) + append("MULTICAST"); + + return result; +} + +int ipv6PrefixLenFromMask(const sockaddr *sa) { + if (!sa || sa->sa_family != AF_INET6) + return -1; + + const auto *sin6 = reinterpret_cast(sa); + int bits = 0; + for (int i = 0; i < 16; ++i) { + const unsigned char byte = sin6->sin6_addr.s6_addr[i]; + if (byte == 0xFF) { + bits += 8; + continue; + } + for (int bit = 7; bit >= 0; --bit) { + if (byte & 1u << bit) + ++bits; + else + return bits; + } + return bits; + } + return bits; +} + +bool addressEntryLess(const AddressEntry &a, const AddressEntry &b) { + if (a.family != b.family) + return a.family == AF_INET; + return a.address < b.address; +} + +const char *ifTypeName(const int type) { + switch (type) { + case 1: + return "ETHER"; + case 772: + return "LOOPBACK"; + case 65534: + return "TUN"; + default: + return nullptr; + } +} + +std::string buildIfconfigLikeBlock(const InterfaceDump &iface, const std::map &mtuMap, + const std::map &txQueueMap, + const std::map &typeMap) { + std::ostringstream oss; + + oss << iface.name << ": flags=" << iface.flags << "<" << formatFlagNames(iface.flags) << ">"; + + if (auto it = mtuMap.find(iface.name); it != mtuMap.end()) + oss << " mtu " << it->second; + + if (auto it = typeMap.find(iface.name); it != typeMap.end()) { + const int t = it->second; + oss << " type " << t; + if (const char *name = ifTypeName(t)) + oss << " (" << name << ")"; + } + oss << "\n"; + + auto sorted = iface.addresses; + std::sort(sorted.begin(), sorted.end(), addressEntryLess); + + for (const auto &entry : sorted) { + if (entry.family == AF_INET) { + oss << " inet " << (entry.address.empty() ? "-" : entry.address); + if (!entry.netmask.empty()) + oss << " netmask " << entry.netmask; + if (!entry.peerOrBroadcast.empty()) { + if (entry.isPointToPoint) + oss << " destination " << entry.peerOrBroadcast; + else if (entry.isBroadcast) + oss << " broadcast " << entry.peerOrBroadcast; + } + oss << "\n"; + } else if (entry.family == AF_INET6) { + oss << " inet6 " << (entry.address.empty() ? "-" : entry.address); + if (!entry.netmask.empty()) + oss << " prefixlen " << entry.netmask; + if (!entry.peerOrBroadcast.empty() && entry.isPointToPoint) + oss << " destination " << entry.peerOrBroadcast; + oss << "\n"; + } + } + + if (auto it = txQueueMap.find(iface.name); it != txQueueMap.end()) + oss << " txqueuelen " << it->second << "\n"; + + return oss.str(); +} + +jobjectArray createStringArray(JNIEnv *env, const std::vector &strings) { + jclass stringCls = env->FindClass("java/lang/String"); + if (!stringCls) + return nullptr; + + const auto size = static_cast(strings.size()); + jobjectArray result = env->NewObjectArray(size, stringCls, nullptr); + if (!result) + return nullptr; + + for (jsize i = 0; i < size; ++i) { + if (jstring text = env->NewStringUTF(strings[i].c_str())) { + env->SetObjectArrayElement(result, i, text); + env->DeleteLocalRef(text); + } + } + return result; +} + +// --- /proc/net/route helpers --- +std::string hexLeToIpStr(const std::string &hex) { + const auto val = strtoul(hex.c_str(), nullptr, 16); + return std::to_string(val & 0xFFu) + "." + std::to_string((val >> 8u) & 0xFFu) + "." + + std::to_string((val >> 16u) & 0xFFu) + "." + std::to_string((val >> 24u) & 0xFFu); +} + +int countSetBits(unsigned long val) { + int count = 0; + while (val) { + count += static_cast(val & 1u); + val >>= 1u; + } + return count; +} + +std::string hexToIpv6Str(const std::string &hex) { + if (hex.size() != 32) + return {}; + + std::array bytes{}; + for (size_t i = 0; i < 16; ++i) { + const auto part = hex.substr(i * 2, 2); + char *end = nullptr; + const auto value = strtoul(part.c_str(), &end, 16); + if (end == nullptr || *end != '\0' || value > 0xFFu) + return {}; + bytes[i] = static_cast(value); + } + + char buf[INET6_ADDRSTRLEN] = {}; + return inet_ntop(AF_INET6, bytes.data(), buf, sizeof(buf)) ? std::string(buf) : std::string(); +} + +std::vector collectInterfaceDumps() { + std::map interfaces; + std::map mtuMap, txQueueMap, typeMap; + + IfAddrsGuard ipaddr; + if (getifaddrs(ipaddr()) == -1 || ipaddr.get() == nullptr) { + return {}; + } + + for (const ::ifaddrs *it = ipaddr.get(); it != nullptr; it = it->ifa_next) { + if (!it->ifa_name) + continue; + + const std::string name(it->ifa_name); + auto &iface = interfaces[name]; + iface.name = name; + iface.flags |= it->ifa_flags; + + if (mtuMap.find(name) == mtuMap.end()) + mtuMap[name] = readIntFromFile("/sys/class/net/" + name + "/mtu"); + if (txQueueMap.find(name) == txQueueMap.end()) + txQueueMap[name] = readIntFromFile("/sys/class/net/" + name + "/tx_queue_len"); + if (typeMap.find(name) == typeMap.end()) + typeMap[name] = readIntFromFile("/sys/class/net/" + name + "/type"); + + if (!it->ifa_addr) + continue; + + const int family = it->ifa_addr->sa_family; + if (family != AF_INET && family != AF_INET6) + continue; + + const bool isP2P = (it->ifa_flags & IFF_POINTOPOINT) != 0; + const bool isBC = (it->ifa_flags & IFF_BROADCAST) != 0; + + std::string netmaskStr, peerStr; + if (family == AF_INET) { + netmaskStr = sockaddrToString(it->ifa_netmask); + if (isP2P && it->ifa_dstaddr) + peerStr = sockaddrToString(it->ifa_dstaddr); + else if (isBC && it->ifa_ifu.ifu_broadaddr) + peerStr = sockaddrToString(it->ifa_ifu.ifu_broadaddr); + } else if (family == AF_INET6) { + if (const int prefixLen = ipv6PrefixLenFromMask(it->ifa_netmask); prefixLen >= 0) + netmaskStr = std::to_string(prefixLen); + if (isP2P && it->ifa_dstaddr) + peerStr = sockaddrToString(it->ifa_dstaddr); + } + + const std::string adderStr = sockaddrToString(it->ifa_addr); + iface.addresses.emplace_back(family, adderStr, std::move(netmaskStr), std::move(peerStr), isP2P, isBC); + } + + std::vector dumps; + dumps.reserve(interfaces.size()); + for (const auto &[_, iface] : interfaces) { + dumps.emplace_back(buildIfconfigLikeBlock(iface, mtuMap, txQueueMap, typeMap)); + } + return dumps; +} + +std::vector parseKernelRoutes() { + std::ifstream routeFile("/proc/net/route"); + if (!routeFile.is_open()) + return {}; + + std::vector routes; + std::string line; + std::getline(routeFile, line); // skip header + + while (std::getline(routeFile, line)) { + std::istringstream ss(line); + std::string i_face, dest, gw, flagsStr, refCnt, use, metric, mask; + + if (!(ss >> i_face >> dest >> gw >> flagsStr >> refCnt >> use >> metric >> mask)) + continue; + + const auto flags = strtoul(flagsStr.c_str(), nullptr, 16); + if (!(flags & 0x0001u)) + continue; + + const auto maskVal = strtoul(mask.c_str(), nullptr, 16); + const auto destVal = strtoul(dest.c_str(), nullptr, 16); + + std::ostringstream route; + route << i_face << ": " << hexLeToIpStr(dest) << "/" << countSetBits(maskVal); + if (flags & 0x0002u) + route << " via " << hexLeToIpStr(gw); + if (destVal == 0 && maskVal == 0) + route << " [DEFAULT]"; + + routes.emplace_back(route.str()); + } + return routes; +} + +std::vector parseKernelIpv6Routes() { + std::ifstream routeFile("/proc/net/ipv6_route"); + if (!routeFile.is_open()) + return {}; + + std::vector routes; + std::string line; + + while (std::getline(routeFile, line)) { + std::istringstream ss(line); + std::string destHex, destPrefixHex, srcHex, srcPrefixHex, nextHopHex, metricHex, refCntHex, useHex, flagsHex, + iface; + + if (!(ss >> destHex >> destPrefixHex >> srcHex >> srcPrefixHex >> nextHopHex >> metricHex >> refCntHex >> + useHex >> flagsHex >> iface)) + continue; + + if (const auto flags = strtoul(flagsHex.c_str(), nullptr, 16); !(flags & 0x0001u)) + continue; + + const auto destPrefix = strtoul(destPrefixHex.c_str(), nullptr, 16); + const auto dest = hexToIpv6Str(destHex); + const auto nextHop = hexToIpv6Str(nextHopHex); + + if (dest.empty()) + continue; + + std::ostringstream route; + route << iface << ": " << dest << "/" << destPrefix; + if (!nextHop.empty() && nextHop != "::") + route << " via " << nextHop; + if (destPrefix == 0 && dest == "::") + route << " [DEFAULT]"; + + routes.emplace_back(route.str()); + } + return routes; +} + +} // anonymous namespace + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getInterfacesNative(JNIEnv *env, jobject /*thiz*/) { + return createStringArray(env, collectInterfaceDumps()); +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getKernelRoutesNative(JNIEnv *env, + jobject /*thiz*/) { + return createStringArray(env, parseKernelRoutes()); +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_cherepavel_vpndetector_detector_IfconfigTermuxLikeDetector_getKernelIpv6RoutesNative(JNIEnv *env, + jobject /*thiz*/) { + return createStringArray(env, parseKernelIpv6Routes()); +} \ No newline at end of file diff --git a/detector/src/main/cpp/run_clang_format.sh b/detector/src/main/cpp/run_clang_format.sh new file mode 100755 index 0000000..b80c072 --- /dev/null +++ b/detector/src/main/cpp/run_clang_format.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Get the directory this script is located in +PROJECT_FOLDER=$( dirname "$(realpath "$0")" ) +cd $PROJECT_FOLDER || exit 1 + +printf "\n\nPROJECT_FOLDER = ${PROJECT_FOLDER}\n\n" + +# Show errors +printf "\nPrint all clang-format errors:\n\n" +find . -not -path "./cmake-build-*/*" -type f \( -name "*.cpp" -o -name "*.hpp" -o -name "*.h" \) -print0 | xargs -0 -I{} clang-format -i {} --dry-run --Werror -style=file:.clang-format + +# Fix errors +printf "\nApplying fixes...\n" +find . -not -path "./cmake-build-*/*" -type f \( -name "*.cpp" -o -name "*.hpp" -o -name "*.h" \) -print0 | xargs -0 -I{} clang-format -i {} --Werror -style=file:.clang-format +printf "\nDone\n" \ No newline at end of file diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/AlwaysOnVpnDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/AlwaysOnVpnDetector.kt new file mode 100644 index 0000000..aafedc0 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/AlwaysOnVpnDetector.kt @@ -0,0 +1,36 @@ +package com.cherepavel.vpndetector.detector + +import android.net.ConnectivityManager +import android.net.NetworkCapabilities + +object AlwaysOnVpnDetector { + + data class Result( + val lockdownLikely: Boolean, + val summary: String? + ) + + fun detect(connectivityManager: ConnectivityManager): Result { + @Suppress("DEPRECATION") + val allNetworks = connectivityManager.allNetworks + val capsList = allNetworks.mapNotNull { connectivityManager.getNetworkCapabilities(it) } + + val hasVpnNetwork = capsList.any { it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } + + // Under lockdown: VPN is present but every validated path has TRANSPORT_VPN + // (non-VPN paths either don't exist or lost VALIDATED capability). + val hasValidatedNonVpnPath = capsList.any { caps -> + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } + + val lockdownLikely = hasVpnNetwork && !hasValidatedNonVpnPath + + return Result( + lockdownLikely = lockdownLikely, + summary = if (lockdownLikely) { + "VPN present and no validated non-VPN path exists — lockdown mode likely." + } else null + ) + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionEngine.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionEngine.kt new file mode 100644 index 0000000..6fcf7eb --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionEngine.kt @@ -0,0 +1,168 @@ +package com.cherepavel.vpndetector.detector + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import com.cherepavel.vpndetector.model.DetectionSnapshot +import com.cherepavel.vpndetector.util.NetworkSignalAnalyzer +import com.cherepavel.vpndetector.util.TransportInfoFormatter + +class DetectionEngine( + private val context: Context, + private val connectivityManager: ConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, + private val javaInterfacesDetector: JavaInterfacesDetector = JavaInterfacesDetector(), + private val trackedAppsDetector: TrackedAppsDetector = TrackedAppsDetector(context), + private val dynamicVpnAppsDetector: DynamicVpnAppsDetector = DynamicVpnAppsDetector(context), +) : IDetectionEngine { + + companion object { + private val MTU_REGEX = Regex("mtu (\\d+)") + private val TYPE_REGEX = Regex("type (\\d+)") + } + + override fun detect(): DetectionSnapshot { + @Suppress("DEPRECATION") + val allNetworks = connectivityManager.allNetworks + val activeNetwork = connectivityManager.activeNetwork + val activeCapabilities = activeNetwork?.let(connectivityManager::getNetworkCapabilities) + + val vpnNetworks = allNetworks.filter { hasTransportVpn(it) } + val anyVpn = vpnNetworks.isNotEmpty() + val activeVpn = activeCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + + val preferredNetwork = vpnNetworks.firstOrNull() ?: activeNetwork ?: allNetworks.firstOrNull() + val preferredLinkProperties: LinkProperties? = + preferredNetwork?.let(connectivityManager::getLinkProperties) + val preferredCapabilities: NetworkCapabilities? = + preferredNetwork?.let(connectivityManager::getNetworkCapabilities) + + val rawInterfaceName = preferredLinkProperties?.interfaceName + val transportInfoSummary = + TransportInfoFormatter.summarizeVpnTransportInfo(preferredCapabilities) + + val vpnNetwork = vpnNetworks.firstOrNull() + val vpnLinkProps = vpnNetwork?.let(connectivityManager::getLinkProperties) + val vpnCaps = vpnNetwork?.let(connectivityManager::getNetworkCapabilities) + + val vpnRoutes = vpnLinkProps?.routes?.map { route -> + buildString { + append(route.destination.toString()) + route.gateway?.hostAddress?.let { gw -> append(" via $gw") } + if (route.destination.prefixLength == 0) append(" [DEFAULT]") + } + } ?: emptyList() + + val vpnDnsServers = vpnLinkProps?.dnsServers?.mapNotNull { it.hostAddress } ?: emptyList() + val kernelIpv6RoutesResult = IfconfigTermuxLikeDetector.detectKernelIpv6Routes() + val dnsSummary = NetworkSignalAnalyzer.buildDnsSummary( + connectivityManager = connectivityManager, + networks = allNetworks.toList(), + preferredLinkProperties = preferredLinkProperties + ) + val policySummary = NetworkSignalAnalyzer.buildPolicySummary( + activeCapabilities = activeCapabilities, + preferredCapabilities = preferredCapabilities + ) + + val nativeResult = IfconfigTermuxLikeDetector.detect() + val kernelRoutesResult = IfconfigTermuxLikeDetector.detectKernelRoutes() + val javaTunnelNames = javaInterfacesDetector.detectTunnelNames() + + val trackedResult = trackedAppsDetector.detect() + val installedVpnApps = trackedResult.installed.map { "${it.label} (${it.packageName})" } + val dynamicVpnApps = dynamicVpnAppsDetector.detect() + + val tunTypeInterfaces = nativeResult.allInterfaces.mapNotNull { block -> + val firstLine = block.lineSequence().firstOrNull() ?: return@mapNotNull null + val type = TYPE_REGEX.find(firstLine)?.groupValues?.get(1)?.toIntOrNull() + if (type == 65534) firstLine.substringBefore(':').trim() else null + } + val lowMtuInterfaces = nativeResult.allInterfaces.mapNotNull { block -> + val firstLine = block.lineSequence().firstOrNull() ?: return@mapNotNull null + val name = firstLine.substringBefore(':').trim() + val mtu = MTU_REGEX.find(firstLine)?.groupValues?.get(1)?.toIntOrNull() + val type = TYPE_REGEX.find(firstLine)?.groupValues?.get(1)?.toIntOrNull() + if (mtu != null && mtu < 1500 && type != 772 && name != "lo") "$name: mtu $mtu" else null + } + + val alwaysOnResult = AlwaysOnVpnDetector.detect(connectivityManager) + val knownVpnDnsMatches = KnownVpnDnsDetector.detect(dnsSummary.allServers) + val workProfileResult = WorkProfileDetector.detect(context) + val vpnPermissionGranted = VpnPermissionDetector.isThisAppVpnOwner(context) + val vpnBandwidthSummary = vpnCaps?.let { caps -> + val down = caps.linkDownstreamBandwidthKbps + val up = caps.linkUpstreamBandwidthKbps + if (down > 0 || up > 0) "↓ $down Kbps ↑ $up Kbps" else null + } + + val nativeTunnelNames = nativeResult.matchedInterfaces + .map { it.substringBefore(':').trim() } + .distinct() + + val assessment = DetectionScorer.assess( + DetectionSignals( + activeVpn = activeVpn, + anyVpn = anyVpn, + rawInterfaceName = rawInterfaceName, + transportInfoSummary = transportInfoSummary, + nativeTunnelNames = nativeTunnelNames, + javaTunnelNames = javaTunnelNames, + installedVpnApps = installedVpnApps, + internalDnsServers = dnsSummary.internalServers, + contextualInternalDnsServers = dnsSummary.contextualInternalServers, + activeNetworkNotVpn = policySummary.activeNetworkNotVpn, + preferredNetworkNotVpn = policySummary.preferredNetworkNotVpn, + tunTypeInterfaces = tunTypeInterfaces, + lowMtuInterfaces = lowMtuInterfaces, + lockdownLikely = alwaysOnResult.lockdownLikely, + knownVpnDnsMatches = knownVpnDnsMatches + ) + ) + + return DetectionSnapshot( + hasTransportVpnAny = anyVpn, + hasTransportVpnActive = activeVpn, + rawInterfaceName = rawInterfaceName, + transportInfoSummary = transportInfoSummary, + nativeTunnelNames = nativeTunnelNames, + nativeDetails = nativeResult.allInterfaces, + javaTunnelNames = javaTunnelNames, + installedVpnApps = installedVpnApps, + dynamicVpnApps = dynamicVpnApps, + vpnRoutes = vpnRoutes, + vpnDnsServers = vpnDnsServers, + allDnsServers = dnsSummary.allServers, + internalDnsServers = dnsSummary.internalServers, + contextualInternalDnsServers = dnsSummary.contextualInternalServers, + privateDnsActive = dnsSummary.privateDnsActive, + privateDnsServerName = dnsSummary.privateDnsServerName, + activeNetworkNotVpn = policySummary.activeNetworkNotVpn, + preferredNetworkNotVpn = policySummary.preferredNetworkNotVpn, + kernelRoutes = kernelRoutesResult.routes, + kernelIpv6Routes = kernelIpv6RoutesResult.routes, + tunTypeInterfaces = tunTypeInterfaces, + lowMtuInterfaces = lowMtuInterfaces, + vpnPermissionGranted = vpnPermissionGranted, + vpnBandwidthSummary = vpnBandwidthSummary, + nativeError = listOfNotNull( + nativeResult.nativeError, + kernelRoutesResult.error?.let { "Kernel routes: $it" }, + kernelIpv6RoutesResult.error?.let { "Kernel IPv6 routes: $it" } + ).joinToString("\n").takeIf { it.isNotBlank() }, + trackedAppsErrors = trackedResult.errors, + lockdownLikely = alwaysOnResult.lockdownLikely, + knownVpnDnsMatches = knownVpnDnsMatches, + workProfileCount = workProfileResult.profileCount, + isManagedProfile = workProfileResult.isManagedProfile, + assessment = assessment + ) + } + + private fun hasTransportVpn(network: Network): Boolean { + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionScorer.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionScorer.kt new file mode 100644 index 0000000..2670a62 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionScorer.kt @@ -0,0 +1,140 @@ +package com.cherepavel.vpndetector.detector + +import com.cherepavel.vpndetector.model.DetectionAssessment +import com.cherepavel.vpndetector.model.DetectionCategory +import com.cherepavel.vpndetector.model.DetectionConfidence +import com.cherepavel.vpndetector.model.DetectionEvidence +import com.cherepavel.vpndetector.model.DetectionStatus + +object DetectionScorer { + + fun assess(signals: DetectionSignals): DetectionAssessment { + val interfaceDetected = TunnelNameMatcher.looksLikeTunnelName(signals.rawInterfaceName) + val transportInfoDetected = !signals.transportInfoSummary.isNullOrBlank() + + val evidence = listOf( + DetectionEvidence( + key = "active_transport_vpn", + category = DetectionCategory.OFFICIAL, + weight = 100, + present = signals.activeVpn, + summary = "Android marks the active network with TRANSPORT_VPN." + ), + DetectionEvidence( + key = "background_transport_vpn", + category = DetectionCategory.OFFICIAL, + weight = 70, + present = signals.anyVpn && !signals.activeVpn, + summary = "Android sees a VPN network, but not on the current active path." + ), + DetectionEvidence( + key = "tunnel_like_interface", + category = DetectionCategory.HEURISTIC, + weight = 25, + present = interfaceDetected, + summary = "LinkProperties exposed a tunnel-like interface name." + ), + DetectionEvidence( + key = "vpn_transport_info", + category = DetectionCategory.HEURISTIC, + weight = 25, + present = transportInfoDetected, + summary = "NetworkCapabilities exposed VPN-related transport info." + ), + DetectionEvidence( + key = "native_tunnel_interfaces", + category = DetectionCategory.HEURISTIC, + weight = 30, + present = signals.nativeTunnelNames.isNotEmpty(), + summary = "Native getifaddrs() found tunnel-like interfaces." + ), + DetectionEvidence( + key = "java_tunnel_interfaces", + category = DetectionCategory.HEURISTIC, + weight = 20, + present = signals.javaTunnelNames.isNotEmpty(), + summary = "Java NetworkInterface enumeration found tunnel-like interfaces." + ), + DetectionEvidence( + key = "internal_dns_on_tunnel", + category = DetectionCategory.HEURISTIC, + weight = 25, + present = signals.internalDnsServers.isNotEmpty(), + summary = "Internal/private DNS servers were observed on tunnel-like interfaces." + ), + DetectionEvidence( + key = "not_vpn_capability_cleared", + category = DetectionCategory.HEURISTIC, + weight = 15, + present = signals.activeNetworkNotVpn == false || signals.preferredNetworkNotVpn == false, + summary = "At least one inspected network cleared NET_CAPABILITY_NOT_VPN." + ), + DetectionEvidence( + key = "tun_interface_type", + category = DetectionCategory.HEURISTIC, + weight = 15, + present = signals.tunTypeInterfaces.isNotEmpty(), + summary = "A Linux TUN interface type was observed." + ), + DetectionEvidence( + key = "low_mtu_interface", + category = DetectionCategory.CONTEXT, + weight = 5, + present = signals.lowMtuInterfaces.isNotEmpty(), + summary = "A low-MTU interface was observed." + ), + DetectionEvidence( + key = "installed_vpn_apps", + category = DetectionCategory.APP, + weight = 10, + present = signals.installedVpnApps.isNotEmpty(), + summary = "Known VPN-related apps are installed on the device." + ), + DetectionEvidence( + key = "carrier_private_dns_context", + category = DetectionCategory.CONTEXT, + weight = 0, + present = signals.contextualInternalDnsServers.isNotEmpty(), + summary = "Carrier private DNS was observed on a cellular interface and is context only." + ), + DetectionEvidence( + key = "lockdown_likely", + category = DetectionCategory.HEURISTIC, + weight = 30, + present = signals.lockdownLikely, + summary = "VPN present and no validated non-VPN path exists — always-on lockdown likely." + ), + DetectionEvidence( + key = "known_vpn_dns", + category = DetectionCategory.HEURISTIC, + weight = 20, + present = signals.knownVpnDnsMatches.isNotEmpty(), + summary = "DNS servers matching known VPN provider addresses were observed." + ), + ) + + val score = evidence.filter { it.present }.sumOf { it.weight }.coerceAtMost(100) + val status = when { + signals.activeVpn -> DetectionStatus.ACTIVE_VPN + signals.anyVpn -> DetectionStatus.SPLIT_TUNNEL + score >= 35 -> DetectionStatus.VPN_LIKE + signals.installedVpnApps.isNotEmpty() -> DetectionStatus.APPS_PRESENT + else -> DetectionStatus.NO_EVIDENCE + } + val confidence = when (status) { + DetectionStatus.ACTIVE_VPN -> DetectionConfidence.CONFIRMED + DetectionStatus.SPLIT_TUNNEL -> DetectionConfidence.LIKELY + DetectionStatus.VPN_LIKE -> DetectionConfidence.LIKELY + DetectionStatus.APPS_PRESENT -> DetectionConfidence.WEAK_SIGNAL + DetectionStatus.NO_EVIDENCE -> + if (score > 0) DetectionConfidence.WEAK_SIGNAL else DetectionConfidence.NO_EVIDENCE + } + + return DetectionAssessment( + status = status, + confidence = confidence, + score = score, + evidence = evidence + ) + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionSignals.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionSignals.kt new file mode 100644 index 0000000..4a23b76 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/DetectionSignals.kt @@ -0,0 +1,19 @@ +package com.cherepavel.vpndetector.detector + +data class DetectionSignals( + val activeVpn: Boolean, + val anyVpn: Boolean, + val rawInterfaceName: String?, + val transportInfoSummary: String?, + val nativeTunnelNames: List, + val javaTunnelNames: List, + val installedVpnApps: List, + val internalDnsServers: List, + val contextualInternalDnsServers: List, + val activeNetworkNotVpn: Boolean?, + val preferredNetworkNotVpn: Boolean?, + val tunTypeInterfaces: List, + val lowMtuInterfaces: List, + val lockdownLikely: Boolean, + val knownVpnDnsMatches: List +) diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/DynamicVpnAppsDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/DynamicVpnAppsDetector.kt new file mode 100644 index 0000000..740233c --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/DynamicVpnAppsDetector.kt @@ -0,0 +1,59 @@ +package com.cherepavel.vpndetector.detector + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build + +class DynamicVpnAppsDetector(private val context: Context) { + + /** Apps that export a service with action android.net.VpnService. */ + fun detectByIntent(): List { + return try { + context.packageManager + .queryIntentServices(Intent("android.net.VpnService"), 0) + .map { it.serviceInfo.packageName } + .distinct() + .sorted() + } catch (_: Throwable) { + emptyList() + } + } + + /** + * Apps that declare a service protected by android.permission.BIND_VPN_SERVICE. + * This catches VPN apps that don't export the service with a standard action. + * + * Requires QUERY_ALL_PACKAGES or a matching element on API 30+. + */ + fun detectByServicePermission(): List { + return try { + val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of(PackageManager.GET_SERVICES.toLong()) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstalledPackages(PackageManager.GET_SERVICES) + } + packages + .filter { pkg -> + pkg.services?.any { svc -> + svc.permission == "android.permission.BIND_VPN_SERVICE" + } == true + } + .map { it.packageName } + .distinct() + .sorted() + } catch (_: Throwable) { + emptyList() + } + } + + /** Combined result: union of both detection methods, deduped. */ + fun detect(): List { + return (detectByIntent() + detectByServicePermission()) + .distinct() + .sorted() + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/IDetectionEngine.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/IDetectionEngine.kt new file mode 100644 index 0000000..8d6adff --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/IDetectionEngine.kt @@ -0,0 +1,7 @@ +package com.cherepavel.vpndetector.detector + +import com.cherepavel.vpndetector.model.DetectionSnapshot + +interface IDetectionEngine { + fun detect(): DetectionSnapshot +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt new file mode 100644 index 0000000..ac321a2 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeDetector.kt @@ -0,0 +1,77 @@ +package com.cherepavel.vpndetector.detector + +data class KernelRoutesResult( + val routes: List, + val error: String? = null +) + +object IfconfigTermuxLikeDetector { + + private var libraryLoaded = false + + init { + libraryLoaded = try { + System.loadLibrary("ifconfigdetector") + true + } catch (_: UnsatisfiedLinkError) { + false + } + } + + external fun getInterfacesNative(): Array + external fun getKernelRoutesNative(): Array + external fun getKernelIpv6RoutesNative(): Array + + fun detect(): IfconfigTermuxLikeResult { + if (!libraryLoaded) { + return IfconfigTermuxLikeResult( + vpnLikely = false, + matchedInterfaces = emptyList(), + allInterfaces = emptyList(), + nativeError = "Native library failed to load" + ) + } + + val allBlocks: List + try { + allBlocks = getInterfacesNative().toList() + } catch (e: Throwable) { + return IfconfigTermuxLikeResult( + vpnLikely = false, + matchedInterfaces = emptyList(), + allInterfaces = emptyList(), + nativeError = "getInterfacesNative failed: ${e.javaClass.simpleName}: ${e.message}" + ) + } + + val matched = allBlocks.filter { block -> + val firstLine = block.lineSequence().firstOrNull().orEmpty() + TunnelNameMatcher.looksLikeTunnelName(firstLine.substringBefore(':').trim()) + } + + return IfconfigTermuxLikeResult( + vpnLikely = matched.isNotEmpty(), + matchedInterfaces = matched, + allInterfaces = allBlocks, + nativeError = null + ) + } + + fun detectKernelRoutes(): KernelRoutesResult { + if (!libraryLoaded) return KernelRoutesResult(emptyList(), "Native library failed to load") + return try { + KernelRoutesResult(getKernelRoutesNative().toList()) + } catch (e: Throwable) { + KernelRoutesResult(emptyList(), "${e.javaClass.simpleName}: ${e.message}") + } + } + + fun detectKernelIpv6Routes(): KernelRoutesResult { + if (!libraryLoaded) return KernelRoutesResult(emptyList(), "Native library failed to load") + return try { + KernelRoutesResult(getKernelIpv6RoutesNative().toList()) + } catch (e: Throwable) { + KernelRoutesResult(emptyList(), "${e.javaClass.simpleName}: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt similarity index 67% rename from app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt rename to detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt index d9816d5..03c7bea 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/IfconfigTermuxLikeResult.kt @@ -3,5 +3,6 @@ package com.cherepavel.vpndetector.detector data class IfconfigTermuxLikeResult( val vpnLikely: Boolean, val matchedInterfaces: List, - val allInterfaces: List + val allInterfaces: List, + val nativeError: String? = null ) diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/JavaInterfacesDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/JavaInterfacesDetector.kt similarity index 100% rename from app/src/main/java/com/cherepavel/vpndetector/detector/JavaInterfacesDetector.kt rename to detector/src/main/java/com/cherepavel/vpndetector/detector/JavaInterfacesDetector.kt diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/KnownVpnDnsDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/KnownVpnDnsDetector.kt new file mode 100644 index 0000000..2414d7c --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/KnownVpnDnsDetector.kt @@ -0,0 +1,33 @@ +package com.cherepavel.vpndetector.detector + +object KnownVpnDnsDetector { + + /** + * Public/semi-public DNS IPs that are specific to known VPN providers. + * Internal RFC-1918 addresses (e.g. ProtonVPN's 10.2.0.1) are already caught + * by NetworkSignalAnalyzer.isSuspiciousInternalDnsAddress and are not listed here. + */ + private val KNOWN_VPN_DNS: Map = mapOf( + "193.19.108.2" to "Mullvad", + "193.19.108.3" to "Mullvad", + "185.95.218.42" to "Mullvad", + "185.95.218.43" to "Mullvad", + "100.100.100.100" to "Tailscale (MagicDNS)", + "103.86.96.100" to "NordVPN", + "103.86.99.100" to "NordVPN", + "10.64.0.1" to "Mullvad (internal)", + ) + + /** + * Receives labeled DNS entries in "iface:address" format from NetworkSignalAnalyzer + * and returns matches as "Provider (address)" strings. + */ + fun detect(labeledDnsServers: List): List { + return labeledDnsServers + .mapNotNull { labeled -> + val address = labeled.substringAfter(':').trim() + KNOWN_VPN_DNS[address]?.let { provider -> "$provider ($address)" } + } + .distinct() + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt new file mode 100644 index 0000000..bf62425 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsDetector.kt @@ -0,0 +1,54 @@ +package com.cherepavel.vpndetector.detector + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.cherepavel.vpndetector.model.TrackedApp + +data class TrackedAppsResult( + val installed: List, + val errors: Map +) + +class TrackedAppsDetector( + private val context: Context +) { + fun detect(): TrackedAppsResult { + val installed = mutableListOf() + val errors = mutableMapOf() + + for (app in TrackedAppsRepository.get(context)) { + when (val result = checkApp(app.packageName)) { + CheckResult.Installed -> installed.add(app) + CheckResult.NotInstalled -> Unit + is CheckResult.Error -> errors[app.packageName] = result.message + } + } + + return TrackedAppsResult(installed = installed, errors = errors) + } + + private sealed class CheckResult { + object Installed : CheckResult() + object NotInstalled : CheckResult() + data class Error(val message: String) : CheckResult() + } + + private fun checkApp(packageName: String): CheckResult { + return try { + val pm = context.packageManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, 0) + } + CheckResult.Installed + } catch (_: PackageManager.NameNotFoundException) { + CheckResult.NotInstalled + } catch (e: Throwable) { + CheckResult.Error("${e.javaClass.simpleName}: ${e.message}") + } + } + +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsRepository.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsRepository.kt new file mode 100644 index 0000000..fbe1528 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/TrackedAppsRepository.kt @@ -0,0 +1,90 @@ +package com.cherepavel.vpndetector.detector + +import android.content.Context +import com.cherepavel.vpndetector.model.TrackedApp +import org.json.JSONArray +import java.net.HttpURLConnection +import java.net.URL +import androidx.core.content.edit + +object TrackedAppsRepository { + + private const val APPS_URL = + "https://raw.githubusercontent.com/cherepavel/VPN-Detector/main/tracked_apps.json" + private const val PREFS_NAME = "vpn_detector" + private const val PREFS_KEY = "tracked_apps_json" + private const val TIMEOUT_MS = 5_000 + + @Volatile private var cached: List? = null + + /** Fetch from remote and update cache. Call from a background thread. */ + fun refresh(context: Context) { + try { + val json = fetch() + val apps = parse(json) + cached = apps + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit { putString(PREFS_KEY, json) } + } catch (_: Exception) { + } + } + + /** Return cached → SharedPreferences → bundled fallback. */ + fun get(context: Context): List { + cached?.let { return it } + val stored = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(PREFS_KEY, null) + if (stored != null) { + return parse(stored).also { cached = it } + } + return fallback + } + + private fun fetch(): String { + val connection = URL(APPS_URL).openConnection() as HttpURLConnection + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + return try { + connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + private fun parse(json: String): List { + val array = JSONArray(json) + return (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + TrackedApp( + packageName = obj.getString("packageName"), + label = obj.getString("label") + ) + } + } + + private val fallback = listOf( + TrackedApp("com.github.dyhkwong.sagernet", "ExclaveVPN"), + TrackedApp("com.v2ray.ang", "v2rayNG"), + TrackedApp("org.amnezia.awg", "AmneziaWG"), + TrackedApp("org.amnezia.vpn", "Amnezia VPN"), + TrackedApp("de.blinkt.openvpn", "OpenVPN for Android"), + TrackedApp("net.openvpn.openvpn", "OpenVPN Connect"), + TrackedApp("com.wireguard.android", "WireGuard"), + TrackedApp("com.cloudflare.onedotonedotonedotone", "Cloudflare WARP"), + TrackedApp("com.psiphon3", "Psiphon"), + TrackedApp("app.hiddify.com", "Hiddify"), + TrackedApp("io.nekohasekai.sfa", "SFA"), + TrackedApp("com.nordvpn.android", "NordVPN"), + TrackedApp("com.expressvpn.vpn", "ExpressVPN"), + TrackedApp("com.protonvpn.android", "Proton VPN"), + TrackedApp("ch.protonvpn.android", "Proton VPN (legacy package)"), + TrackedApp("free.vpn.unblock.proxy.turbovpn", "Turbo VPN"), + TrackedApp("com.zaneschepke.wireguardautotunnel", "WG Tunnel"), + TrackedApp("moe.nb4a", "NekoBox"), + TrackedApp("fr.husi", "husi"), + TrackedApp("com.outline.android", "Outline"), + TrackedApp("xyz.safetyvpn.app", "SafetyVPN"), + TrackedApp("net.mullvad.mullvadvpn", "Mullvad VPN"), + TrackedApp("org.torproject.android", "Orbot") + ) +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt similarity index 86% rename from app/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt rename to detector/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt index 05ac606..11dd4ff 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/TunnelNameMatcher.kt @@ -13,7 +13,12 @@ object TunnelNameMatcher { "ipsec", "xfrm", "zt", - "tailscale" + "tailscale", + "svpn", + "ovpn", + "l2tp", + "gre", + "he-ipv6" ) private val tunnelContains = listOf( diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/VpnPermissionDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/VpnPermissionDetector.kt new file mode 100644 index 0000000..73c6197 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/VpnPermissionDetector.kt @@ -0,0 +1,23 @@ +package com.cherepavel.vpndetector.detector + +import android.content.Context +import android.net.VpnService + +object VpnPermissionDetector { + + /** + * Returns true if the calling app currently holds Android VPN permission. + * + * VpnService.prepare() returns null when the calling app already owns the VPN + * grant, and an Intent otherwise. For a passive detector app this will almost + * always be false. A true result means the detector itself was previously + * granted VPN permission — an anomalous state worth flagging. + */ + fun isThisAppVpnOwner(context: Context): Boolean { + return try { + VpnService.prepare(context) == null + } catch (_: Throwable) { + false + } + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/detector/WorkProfileDetector.kt b/detector/src/main/java/com/cherepavel/vpndetector/detector/WorkProfileDetector.kt new file mode 100644 index 0000000..2444f3d --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/detector/WorkProfileDetector.kt @@ -0,0 +1,41 @@ +package com.cherepavel.vpndetector.detector + +import android.content.Context +import android.os.Build +import android.os.UserManager + +/** + * Detects the presence of a work/managed profile. + * + * VPN apps installed inside a work profile are invisible to the primary user's + * PackageManager, so TrackedAppsDetector and DynamicVpnAppsDetector cannot see them. + * This detector flags the limitation so it can be surfaced in the report. + */ +object WorkProfileDetector { + + data class Result( + val hasMultipleProfiles: Boolean, + val profileCount: Int, + val isManagedProfile: Boolean + ) + + fun detect(context: Context): Result { + val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + + val profileCount = runCatching { userManager.userProfiles.size }.getOrDefault(1) + + val isManagedProfile = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + userManager.isManagedProfile + } else { + false + } + }.getOrDefault(false) + + return Result( + hasMultipleProfiles = profileCount > 1, + profileCount = profileCount, + isManagedProfile = isManagedProfile + ) + } +} diff --git a/detector/src/main/java/com/cherepavel/vpndetector/model/DetectionModels.kt b/detector/src/main/java/com/cherepavel/vpndetector/model/DetectionModels.kt new file mode 100644 index 0000000..5ba1c0c --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/model/DetectionModels.kt @@ -0,0 +1,75 @@ +package com.cherepavel.vpndetector.model + +enum class DetectionCategory { + OFFICIAL, + HEURISTIC, + APP, + CONTEXT +} + +enum class DetectionConfidence { + CONFIRMED, + LIKELY, + WEAK_SIGNAL, + NO_EVIDENCE +} + +enum class DetectionStatus { + ACTIVE_VPN, + SPLIT_TUNNEL, + VPN_LIKE, + APPS_PRESENT, + NO_EVIDENCE +} + +data class DetectionEvidence( + val key: String, + val category: DetectionCategory, + val weight: Int, + val present: Boolean, + val summary: String +) + +data class DetectionAssessment( + val status: DetectionStatus, + val confidence: DetectionConfidence, + val score: Int, + val evidence: List +) + +data class DetectionSnapshot( + val hasTransportVpnAny: Boolean, + val hasTransportVpnActive: Boolean, + val rawInterfaceName: String?, + val transportInfoSummary: String?, + val nativeTunnelNames: List, + val nativeDetails: List, + val javaTunnelNames: List, + val installedVpnApps: List, + val dynamicVpnApps: List, + val vpnRoutes: List, + val vpnDnsServers: List, + val allDnsServers: List, + val internalDnsServers: List, + val contextualInternalDnsServers: List, + val privateDnsActive: Boolean, + val privateDnsServerName: String?, + val activeNetworkNotVpn: Boolean?, + val preferredNetworkNotVpn: Boolean?, + val kernelRoutes: List, + val kernelIpv6Routes: List, + val tunTypeInterfaces: List, + val lowMtuInterfaces: List, + val vpnPermissionGranted: Boolean, + val vpnBandwidthSummary: String?, + val nativeError: String?, + val trackedAppsErrors: Map, + val lockdownLikely: Boolean, + val knownVpnDnsMatches: List, + val workProfileCount: Int, + val isManagedProfile: Boolean, + val assessment: DetectionAssessment +) { + val unknownDynamicApps: List + get() = dynamicVpnApps.filter { pkg -> installedVpnApps.none { it.contains(pkg) } } +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/model/TrackedApp.kt b/detector/src/main/java/com/cherepavel/vpndetector/model/TrackedApp.kt similarity index 100% rename from app/src/main/java/com/cherepavel/vpndetector/model/TrackedApp.kt rename to detector/src/main/java/com/cherepavel/vpndetector/model/TrackedApp.kt diff --git a/app/src/main/java/com/cherepavel/vpndetector/util/Extensions.kt b/detector/src/main/java/com/cherepavel/vpndetector/util/Extensions.kt similarity index 100% rename from app/src/main/java/com/cherepavel/vpndetector/util/Extensions.kt rename to detector/src/main/java/com/cherepavel/vpndetector/util/Extensions.kt diff --git a/detector/src/main/java/com/cherepavel/vpndetector/util/NetworkSignalAnalyzer.kt b/detector/src/main/java/com/cherepavel/vpndetector/util/NetworkSignalAnalyzer.kt new file mode 100644 index 0000000..c214f14 --- /dev/null +++ b/detector/src/main/java/com/cherepavel/vpndetector/util/NetworkSignalAnalyzer.kt @@ -0,0 +1,131 @@ +package com.cherepavel.vpndetector.util + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import com.cherepavel.vpndetector.detector.TunnelNameMatcher +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress + +data class DnsSignalSummary( + val allServers: List, + val internalServers: List, + val contextualInternalServers: List, + val privateDnsActive: Boolean, + val privateDnsServerName: String? +) + +data class VpnPolicySummary( + val activeNetworkNotVpn: Boolean?, + val preferredNetworkNotVpn: Boolean? +) + +object NetworkSignalAnalyzer { + + fun buildDnsSummary( + connectivityManager: ConnectivityManager, + networks: List, + preferredLinkProperties: LinkProperties? + ): DnsSignalSummary { + val labeledServers = linkedSetOf() + val internalServers = linkedSetOf() + val contextualInternalServers = linkedSetOf() + + for (network in networks) { + val linkProperties = connectivityManager.getLinkProperties(network) ?: continue + val iface = linkProperties.interfaceName ?: network.toString() + for (address in linkProperties.dnsServers) { + val hostAddress = address.hostAddress ?: continue + val labeled = "$iface:$hostAddress" + labeledServers += labeled + if (isSuspiciousInternalDnsAddress(iface, address)) { + internalServers += labeled + } else if (isContextualInternalDnsAddress(iface, address)) { + contextualInternalServers += labeled + } + } + } + + val privateDnsActive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + preferredLinkProperties?.isPrivateDnsActive == true + } else { + false + } + val privateDnsServerName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + preferredLinkProperties?.privateDnsServerName?.takeIf { it.isNotBlank() } + } else { + null + } + + return DnsSignalSummary( + allServers = labeledServers.toList(), + internalServers = internalServers.toList(), + contextualInternalServers = contextualInternalServers.toList(), + privateDnsActive = privateDnsActive, + privateDnsServerName = privateDnsServerName + ) + } + + fun buildPolicySummary( + activeCapabilities: NetworkCapabilities?, + preferredCapabilities: NetworkCapabilities? + ): VpnPolicySummary { + return VpnPolicySummary( + activeNetworkNotVpn = activeCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN), + preferredNetworkNotVpn = preferredCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + ) + } + + private fun isSuspiciousInternalDnsAddress( + interfaceName: String?, + address: InetAddress + ): Boolean { + val iface = interfaceName?.trim().orEmpty() + if (!isInternalDnsAddress(address)) return false + if (iface.isBlank()) return false + if (isLikelyCellularInterface(iface)) return false + return TunnelNameMatcher.looksLikeTunnelName(iface) + } + + private fun isContextualInternalDnsAddress( + interfaceName: String?, + address: InetAddress + ): Boolean { + val iface = interfaceName?.trim().orEmpty() + if (!isInternalDnsAddress(address)) return false + if (iface.isBlank()) return false + return isLikelyCellularInterface(iface) + } + + private fun isLikelyCellularInterface(interfaceName: String): Boolean { + val lowered = interfaceName.lowercase() + return lowered.startsWith("rmnet") || + lowered.startsWith("ccmni") || + lowered.startsWith("pdp") || + lowered.startsWith("v4-rmnet") || + lowered.startsWith("vif") + } + + private fun isInternalDnsAddress(address: InetAddress): Boolean { + return when (address) { + is Inet4Address -> { + val bytes = address.address + val first = bytes[0].toInt() and 0xFF + val second = bytes[1].toInt() and 0xFF + first == 10 || + (first == 172 && second in 16..31) || + (first == 192 && second == 168) || + (first == 100 && second in 64..127) + } + + is Inet6Address -> { + val first = address.address[0].toInt() and 0xFE + first == 0xFC + } + else -> false + } + } +} diff --git a/app/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt b/detector/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt similarity index 52% rename from app/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt rename to detector/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt index 6edc0bc..8cf04ca 100644 --- a/app/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt +++ b/detector/src/main/java/com/cherepavel/vpndetector/util/TransportInfoFormatter.kt @@ -11,7 +11,9 @@ object TransportInfoFormatter { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null val transportInfo = capabilities.transportInfo ?: return null - val text = transportInfo.javaClass.simpleName ?: transportInfo.toString() + val simpleName = transportInfo.javaClass.simpleName ?: transportInfo.toString() + val vpnType = readVpnType(transportInfo) + val text = if (vpnType != null) "$simpleName(type=$vpnType)" else simpleName return text .takeIf { it.isNotBlank() } @@ -21,4 +23,20 @@ object TransportInfoFormatter { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } } + + private fun readVpnType(transportInfo: Any): String? { + val className = transportInfo.javaClass.name + if (!className.endsWith("VpnTransportInfo")) return null + + val typeValue = runCatching { + transportInfo.javaClass.getMethod("getType").invoke(transportInfo) as? Int + }.getOrNull() ?: return null + + return when (typeValue) { + 1 -> "PLATFORM" + 2 -> "LEGACY" + 3 -> "IKEV2" + else -> "UNKNOWN:$typeValue" + } + } } diff --git a/gradle.properties b/gradle.properties index 34c5e9e..225cd55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,4 +12,12 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official + +android.useAndroidX=true + +android.suppressUnsupportedCompileSdk=36 +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe5e342..928fb06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] agp = "9.1.0" -coreKtx = "1.10.1" +coreKtx = "1.18.0" junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" -appcompat = "1.6.1" -material = "1.10.0" -activity = "1.8.0" -constraintlayout = "2.1.4" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.13.0" +activity = "1.13.0" +constraintlayout = "2.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -21,4 +21,5 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/metadata/com.cherepavel.vpndetector.yml b/metadata/com.cherepavel.vpndetector.yml new file mode 100644 index 0000000..ce72699 --- /dev/null +++ b/metadata/com.cherepavel.vpndetector.yml @@ -0,0 +1,40 @@ +Categories: + - Security +License: MIT +Web Site: https://github.com/cherepavel/VPN-Detector +Source Code: https://github.com/cherepavel/VPN-Detector +Issue Tracker: https://github.com/cherepavel/VPN-Detector/issues + +AutoName: VPN Detector +Summary: Detects active VPN connections and interfaces on Android + +Description: | + VPN Detector is a research and diagnostic tool for analyzing VPN detection + mechanisms on Android. No root required, no ads, no tracking. + + It shows: + * Active VPN interfaces (tun0, wg0, utun, etc.) + * Kernel routing table + * DNS servers in use + * Apps currently using VPN + * NetworkCapabilities.TRANSPORT_VPN state + * Always-on VPN and Work Profile detection + * Local proxy and known VPN DNS detection + + Useful for privacy researchers, developers, and power users who want to + understand how applications can detect VPN presence even with split tunneling. + + VPN Detector — диагностический инструмент для анализа механизмов обнаружения + VPN на Android. Без root, без рекламы, без слежки. + +Builds: + - versionName: '1.0' + versionCode: 1 + commit: v1.0 + subdir: app + gradle: + - yes + ndk: 27.2.12479018 + +AutoUpdateMode: Version v%v +UpdateCheckMode: Tags \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 67f61c6..5eda043 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,19 +1,17 @@ pluginManagement { repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } } + plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("com.android.library") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -22,5 +20,6 @@ dependencyResolutionManagement { } } -rootProject.name = "VpnDetector" +rootProject.name = "VPN-Detector" include(":app") +include(":detector") \ No newline at end of file diff --git a/tracked_apps.json b/tracked_apps.json new file mode 100644 index 0000000..6834d0f --- /dev/null +++ b/tracked_apps.json @@ -0,0 +1,25 @@ +[ + {"packageName": "com.github.dyhkwong.sagernet", "label": "ExclaveVPN"}, + {"packageName": "com.v2ray.ang", "label": "v2rayNG"}, + {"packageName": "org.amnezia.awg", "label": "AmneziaWG"}, + {"packageName": "org.amnezia.vpn", "label": "Amnezia VPN"}, + {"packageName": "de.blinkt.openvpn", "label": "OpenVPN for Android"}, + {"packageName": "net.openvpn.openvpn", "label": "OpenVPN Connect"}, + {"packageName": "com.wireguard.android", "label": "WireGuard"}, + {"packageName": "com.cloudflare.onedotonedotonedotone", "label": "Cloudflare WARP"}, + {"packageName": "com.psiphon3", "label": "Psiphon"}, + {"packageName": "app.hiddify.com", "label": "Hiddify"}, + {"packageName": "io.nekohasekai.sfa", "label": "SFA"}, + {"packageName": "com.nordvpn.android", "label": "NordVPN"}, + {"packageName": "com.expressvpn.vpn", "label": "ExpressVPN"}, + {"packageName": "com.protonvpn.android", "label": "Proton VPN"}, + {"packageName": "ch.protonvpn.android", "label": "Proton VPN (legacy package)"}, + {"packageName": "free.vpn.unblock.proxy.turbovpn", "label": "Turbo VPN"}, + {"packageName": "com.zaneschepke.wireguardautotunnel", "label": "WG Tunnel"}, + {"packageName": "moe.nb4a", "label": "NekoBox"}, + {"packageName": "fr.husi", "label": "husi"}, + {"packageName": "com.outline.android", "label": "Outline"}, + {"packageName": "xyz.safetyvpn.app", "label": "SafetyVPN"}, + {"packageName": "net.mullvad.mullvadvpn", "label": "Mullvad VPN"}, + {"packageName": "org.torproject.android", "label": "Orbot"} +]