mirror of
https://github.com/cherepavel/VPN-Detector.git
synced 2026-08-18 05:24:00 +00:00
Merge pull request #11 from wecand0/fdroid
внедрение detection engine и модульной архитектуры детектирования VPN
This commit is contained in:
parent
702874ca79
commit
26fc550b7f
47 changed files with 2241 additions and 727 deletions
|
|
@ -2,9 +2,8 @@ plugins {
|
|||
id("com.android.application")
|
||||
}
|
||||
|
||||
android {
|
||||
extensions.configure<com.android.build.api.dsl.ApplicationExtension> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||
tools:ignore="ForegroundServicePermission" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.github.dyhkwong.sagernet" />
|
||||
<package android:name="com.v2ray.ang" />
|
||||
<package android:name="org.amnezia.awg" />
|
||||
<package android:name="org.amnezia.vpn" />
|
||||
<package android:name="de.blinkt.openvpn" />
|
||||
<package android:name="net.openvpn.openvpn" />
|
||||
<package android:name="com.zaneschepke.wireguardautotunnel" />
|
||||
<package android:name="moe.nb4a" />
|
||||
</queries>
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="PackageVisibilityPolicy,ProtectionLevel,QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
@ -31,6 +32,11 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.VpnMonitorService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
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<AddressEntry> 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<const sockaddr_in*>(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<const sockaddr_in6*>(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<std::string> 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<const sockaddr_in6*>(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<std::string, int>& mtuMap, const std::map<std::string, int>& 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<AddressEntry> 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<std::string, InterfaceDump> interfaces;
|
||||
std::map<std::string, int> mtuMap;
|
||||
std::map<std::string, int> 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<unsigned int>(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<std::string> dumps;
|
||||
dumps.reserve(interfaces.size());
|
||||
|
||||
for (const auto& pair : interfaces) {
|
||||
dumps.push_back(buildIfconfigLikeBlock(pair.second, mtuMap, txQueueMap));
|
||||
}
|
||||
|
||||
jobjectArray result = env->NewObjectArray(
|
||||
static_cast<jsize>(dumps.size()),
|
||||
stringCls,
|
||||
nullptr
|
||||
);
|
||||
|
||||
for (jsize i = 0; i < static_cast<jsize>(dumps.size()); ++i) {
|
||||
jstring text = env->NewStringUTF(dumps[i].c_str());
|
||||
env->SetObjectArrayElement(result, i, text);
|
||||
env->DeleteLocalRef(text);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -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<TextView>
|
||||
private lateinit var apiSignalHints: List<TextView>
|
||||
|
||||
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<String> = emptyList()
|
||||
private var lastInstalledVpnApps: List<String> = 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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package com.cherepavel.vpndetector.detector
|
||||
|
||||
object IfconfigTermuxLikeDetector {
|
||||
|
||||
init {
|
||||
try {
|
||||
System.loadLibrary("ifconfigdetector")
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
external fun getInterfacesNative(): Array<String>
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TrackedApp> {
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VpnNetworkInfo>
|
||||
)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.cherepavel.vpndetector.model
|
||||
|
||||
data class VpnNetworkInfo(
|
||||
val interfaceName: String?,
|
||||
val transports: List<String>,
|
||||
val capabilities: List<String>,
|
||||
val transportInfoSummary: String?
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
val installedVpnApps: List<String>
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SignalItem>,
|
||||
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<String>,
|
||||
val nativeDetails: List<String>,
|
||||
val javaTunnelNames: List<String>,
|
||||
val installedVpnApps: List<String>
|
||||
)
|
||||
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<String>,
|
||||
internalDnsServers: List<String>,
|
||||
contextualInternalDnsServers: List<String>,
|
||||
privateDnsActive: Boolean,
|
||||
privateDnsServerName: String?,
|
||||
activeNetworkNotVpn: Boolean?,
|
||||
preferredNetworkNotVpn: Boolean?,
|
||||
activeVpn: Boolean,
|
||||
anyVpn: Boolean
|
||||
): List<SignalItem> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
39
detector/build.gradle.kts
Normal file
39
detector/build.gradle.kts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
extensions.configure<com.android.build.api.dsl.LibraryExtension> {
|
||||
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)
|
||||
}
|
||||
6
detector/src/main/AndroidManifest.xml
Normal file
6
detector/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
</manifest>
|
||||
14
detector/src/main/cpp/.clang-format
Normal file
14
detector/src/main/cpp/.clang-format
Normal file
|
|
@ -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
|
||||
40
detector/src/main/cpp/CMakeLists.txt
Normal file
40
detector/src/main/cpp/CMakeLists.txt
Normal file
|
|
@ -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
|
||||
$<$<CONFIG:Release>:-O3 -DNDEBUG>
|
||||
$<$<CONFIG:Debug>:-g -O0>
|
||||
)
|
||||
|
||||
set_target_properties(${PROJECT_NAME} PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON
|
||||
OUTPUT_NAME ${PROJECT_NAME}
|
||||
)
|
||||
410
detector/src/main/cpp/ifconfigdetector.cpp
Normal file
410
detector/src/main/cpp/ifconfigdetector.cpp
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
#include <jni.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
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<AddressEntry> 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<const sockaddr_in *>(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<const sockaddr_in6 *>(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<const sockaddr_in6 *>(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<std::string, int> &mtuMap,
|
||||
const std::map<std::string, int> &txQueueMap,
|
||||
const std::map<std::string, int> &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<std::string> &strings) {
|
||||
jclass stringCls = env->FindClass("java/lang/String");
|
||||
if (!stringCls)
|
||||
return nullptr;
|
||||
|
||||
const auto size = static_cast<jsize>(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<int>(val & 1u);
|
||||
val >>= 1u;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
std::string hexToIpv6Str(const std::string &hex) {
|
||||
if (hex.size() != 32)
|
||||
return {};
|
||||
|
||||
std::array<unsigned char, 16> 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<unsigned char>(value);
|
||||
}
|
||||
|
||||
char buf[INET6_ADDRSTRLEN] = {};
|
||||
return inet_ntop(AF_INET6, bytes.data(), buf, sizeof(buf)) ? std::string(buf) : std::string();
|
||||
}
|
||||
|
||||
std::vector<std::string> collectInterfaceDumps() {
|
||||
std::map<std::string, InterfaceDump> interfaces;
|
||||
std::map<std::string, int> 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<std::string> dumps;
|
||||
dumps.reserve(interfaces.size());
|
||||
for (const auto &[_, iface] : interfaces) {
|
||||
dumps.emplace_back(buildIfconfigLikeBlock(iface, mtuMap, txQueueMap, typeMap));
|
||||
}
|
||||
return dumps;
|
||||
}
|
||||
|
||||
std::vector<std::string> parseKernelRoutes() {
|
||||
std::ifstream routeFile("/proc/net/route");
|
||||
if (!routeFile.is_open())
|
||||
return {};
|
||||
|
||||
std::vector<std::string> 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<std::string> parseKernelIpv6Routes() {
|
||||
std::ifstream routeFile("/proc/net/ipv6_route");
|
||||
if (!routeFile.is_open())
|
||||
return {};
|
||||
|
||||
std::vector<std::string> 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());
|
||||
}
|
||||
16
detector/src/main/cpp/run_clang_format.sh
Executable file
16
detector/src/main/cpp/run_clang_format.sh
Executable file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
val javaTunnelNames: List<String>,
|
||||
val installedVpnApps: List<String>,
|
||||
val internalDnsServers: List<String>,
|
||||
val contextualInternalDnsServers: List<String>,
|
||||
val activeNetworkNotVpn: Boolean?,
|
||||
val preferredNetworkNotVpn: Boolean?,
|
||||
val tunTypeInterfaces: List<String>,
|
||||
val lowMtuInterfaces: List<String>,
|
||||
val lockdownLikely: Boolean,
|
||||
val knownVpnDnsMatches: List<String>
|
||||
)
|
||||
|
|
@ -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<String> {
|
||||
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 <queries> element on API 30+.
|
||||
*/
|
||||
fun detectByServicePermission(): List<String> {
|
||||
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<String> {
|
||||
return (detectByIntent() + detectByServicePermission())
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.cherepavel.vpndetector.detector
|
||||
|
||||
import com.cherepavel.vpndetector.model.DetectionSnapshot
|
||||
|
||||
interface IDetectionEngine {
|
||||
fun detect(): DetectionSnapshot
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.cherepavel.vpndetector.detector
|
||||
|
||||
data class KernelRoutesResult(
|
||||
val routes: List<String>,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
object IfconfigTermuxLikeDetector {
|
||||
|
||||
private var libraryLoaded = false
|
||||
|
||||
init {
|
||||
libraryLoaded = try {
|
||||
System.loadLibrary("ifconfigdetector")
|
||||
true
|
||||
} catch (_: UnsatisfiedLinkError) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
external fun getInterfacesNative(): Array<String>
|
||||
external fun getKernelRoutesNative(): Array<String>
|
||||
external fun getKernelIpv6RoutesNative(): Array<String>
|
||||
|
||||
fun detect(): IfconfigTermuxLikeResult {
|
||||
if (!libraryLoaded) {
|
||||
return IfconfigTermuxLikeResult(
|
||||
vpnLikely = false,
|
||||
matchedInterfaces = emptyList(),
|
||||
allInterfaces = emptyList(),
|
||||
nativeError = "Native library failed to load"
|
||||
)
|
||||
}
|
||||
|
||||
val allBlocks: List<String>
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,5 +3,6 @@ package com.cherepavel.vpndetector.detector
|
|||
data class IfconfigTermuxLikeResult(
|
||||
val vpnLikely: Boolean,
|
||||
val matchedInterfaces: List<String>,
|
||||
val allInterfaces: List<String>
|
||||
val allInterfaces: List<String>,
|
||||
val nativeError: String? = null
|
||||
)
|
||||
|
|
@ -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<String, String> = 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<String>): List<String> {
|
||||
return labeledDnsServers
|
||||
.mapNotNull { labeled ->
|
||||
val address = labeled.substringAfter(':').trim()
|
||||
KNOWN_VPN_DNS[address]?.let { provider -> "$provider ($address)" }
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TrackedApp>,
|
||||
val errors: Map<String, String>
|
||||
)
|
||||
|
||||
class TrackedAppsDetector(
|
||||
private val context: Context
|
||||
) {
|
||||
fun detect(): TrackedAppsResult {
|
||||
val installed = mutableListOf<TrackedApp>()
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<TrackedApp>? = 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<TrackedApp> {
|
||||
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<TrackedApp> {
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,12 @@ object TunnelNameMatcher {
|
|||
"ipsec",
|
||||
"xfrm",
|
||||
"zt",
|
||||
"tailscale"
|
||||
"tailscale",
|
||||
"svpn",
|
||||
"ovpn",
|
||||
"l2tp",
|
||||
"gre",
|
||||
"he-ipv6"
|
||||
)
|
||||
|
||||
private val tunnelContains = listOf(
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DetectionEvidence>
|
||||
)
|
||||
|
||||
data class DetectionSnapshot(
|
||||
val hasTransportVpnAny: Boolean,
|
||||
val hasTransportVpnActive: Boolean,
|
||||
val rawInterfaceName: String?,
|
||||
val transportInfoSummary: String?,
|
||||
val nativeTunnelNames: List<String>,
|
||||
val nativeDetails: List<String>,
|
||||
val javaTunnelNames: List<String>,
|
||||
val installedVpnApps: List<String>,
|
||||
val dynamicVpnApps: List<String>,
|
||||
val vpnRoutes: List<String>,
|
||||
val vpnDnsServers: List<String>,
|
||||
val allDnsServers: List<String>,
|
||||
val internalDnsServers: List<String>,
|
||||
val contextualInternalDnsServers: List<String>,
|
||||
val privateDnsActive: Boolean,
|
||||
val privateDnsServerName: String?,
|
||||
val activeNetworkNotVpn: Boolean?,
|
||||
val preferredNetworkNotVpn: Boolean?,
|
||||
val kernelRoutes: List<String>,
|
||||
val kernelIpv6Routes: List<String>,
|
||||
val tunTypeInterfaces: List<String>,
|
||||
val lowMtuInterfaces: List<String>,
|
||||
val vpnPermissionGranted: Boolean,
|
||||
val vpnBandwidthSummary: String?,
|
||||
val nativeError: String?,
|
||||
val trackedAppsErrors: Map<String, String>,
|
||||
val lockdownLikely: Boolean,
|
||||
val knownVpnDnsMatches: List<String>,
|
||||
val workProfileCount: Int,
|
||||
val isManagedProfile: Boolean,
|
||||
val assessment: DetectionAssessment
|
||||
) {
|
||||
val unknownDynamicApps: List<String>
|
||||
get() = dynamicVpnApps.filter { pkg -> installedVpnApps.none { it.contains(pkg) } }
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
val internalServers: List<String>,
|
||||
val contextualInternalServers: List<String>,
|
||||
val privateDnsActive: Boolean,
|
||||
val privateDnsServerName: String?
|
||||
)
|
||||
|
||||
data class VpnPolicySummary(
|
||||
val activeNetworkNotVpn: Boolean?,
|
||||
val preferredNetworkNotVpn: Boolean?
|
||||
)
|
||||
|
||||
object NetworkSignalAnalyzer {
|
||||
|
||||
fun buildDnsSummary(
|
||||
connectivityManager: ConnectivityManager,
|
||||
networks: List<Network>,
|
||||
preferredLinkProperties: LinkProperties?
|
||||
): DnsSignalSummary {
|
||||
val labeledServers = linkedSetOf<String>()
|
||||
val internalServers = linkedSetOf<String>()
|
||||
val contextualInternalServers = linkedSetOf<String>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
kotlin.code.style=official
|
||||
|
||||
android.useAndroidX=true
|
||||
|
||||
android.suppressUnsupportedCompileSdk=36
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
0
gradlew
vendored
Normal file → Executable file
0
gradlew
vendored
Normal file → Executable file
40
metadata/com.cherepavel.vpndetector.yml
Normal file
40
metadata/com.cherepavel.vpndetector.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
25
tracked_apps.json
Normal file
25
tracked_apps.json
Normal file
|
|
@ -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"}
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue