diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a86ba0c..fd57ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,8 +192,24 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Install Rust + cargo-ndk + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + . "$HOME/.cargo/env" + rustup target add aarch64-linux-android + cargo install cargo-ndk --locked + + - name: Set up Android NDK + uses: nttld/setup-ndk@v1 + id: ndk + with: + ndk-version: r28b + - name: Build APK - run: cd test-app && ./gradlew --no-daemon assembleDebug + run: | + . "$HOME/.cargo/env" + export ANDROID_NDK_HOME=${{ steps.ndk.outputs.ndk-path }} + cd test-app && ./gradlew --no-daemon assembleDebug - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 21e0e3b..811491b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ test-app/.gradle/ test-app/build/ test-app/app/build/ test-app/app/.cxx/ +test-app/app/src/main/jniLibs/ +test-app/native/target/ test-app/.idea/ test-app/*.iml test-app/local.properties diff --git a/test-app/app/build.gradle.kts b/test-app/app/build.gradle.kts index 5acabaa..3eef3d6 100644 --- a/test-app/app/build.gradle.kts +++ b/test-app/app/build.gradle.kts @@ -15,12 +15,6 @@ android { versionCode = 1 versionName = "1.0" - externalNativeBuild { - cmake { - cppFlags += "" - } - } - ndk { abiFilters += listOf("arm64-v8a") } @@ -44,15 +38,32 @@ android { buildFeatures { compose = true } +} - externalNativeBuild { - cmake { - path = file("src/main/cpp/CMakeLists.txt") - version = "3.22.1" +// Build the Rust native library via cargo-ndk and copy to jniLibs +// before Gradle processes native libraries. +// Build Rust native library via cargo-ndk. Always runs (cargo handles +// its own incremental build caching), then copies the .so to jniLibs. +val buildRustNative by tasks.registering { + // Never skip — cargo's own up-to-date check is authoritative. + outputs.upToDateWhen { false } + + doLast { + exec { + workingDir = file("../native") + commandLine("cargo", "ndk", "-t", "arm64-v8a", "build", "--release") } + val src = file("../native/target/aarch64-linux-android/release/libvpnhide_test.so") + val dst = file("src/main/jniLibs/arm64-v8a/libvpnhide_test.so") + dst.parentFile.mkdirs() + src.copyTo(dst, overwrite = true) } } +tasks.named("preBuild") { + dependsOn(buildRustNative) +} + dependencies { implementation(libs.core.ktx) implementation(libs.activity.compose) diff --git a/test-app/app/src/main/cpp/CMakeLists.txt b/test-app/app/src/main/cpp/CMakeLists.txt deleted file mode 100644 index 179390c..0000000 --- a/test-app/app/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,12 +0,0 @@ -cmake_minimum_required(VERSION 3.22.1) -project("vpnhide_test") - -add_library(vpnhide_test SHARED native-lib.cpp) - -# 16 KB page size alignment for Android 15+ / Pixel 8 Pro -target_link_options(vpnhide_test PRIVATE "-Wl,-z,max-page-size=16384") - -target_link_libraries(vpnhide_test - android - log -) diff --git a/test-app/app/src/main/cpp/native-lib.cpp b/test-app/app/src/main/cpp/native-lib.cpp deleted file mode 100644 index 2d07897..0000000 --- a/test-app/app/src/main/cpp/native-lib.cpp +++ /dev/null @@ -1,527 +0,0 @@ -#include -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define TAG "VPNHideTest" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) - -static const char* VPN_PREFIXES[] = {"tun", "wg", "ppp", "tap", "ipsec", "xfrm"}; -static const int NUM_PREFIXES = sizeof(VPN_PREFIXES) / sizeof(VPN_PREFIXES[0]); - -static bool is_vpn_iface(const char* name) { - for (int i = 0; i < NUM_PREFIXES; i++) { - if (strncmp(name, VPN_PREFIXES[i], strlen(VPN_PREFIXES[i])) == 0) { - return true; - } - } - if (strstr(name, "vpn") || strstr(name, "VPN")) return true; - return false; -} - -static jstring to_jstring(JNIEnv* env, const std::string& s) { - return env->NewStringUTF(s.c_str()); -} - -// 1. ioctl SIOCGIFFLAGS on tun0 -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkIoctlSiocgifflags(JNIEnv* env, jobject) { - LOGI("=== CHECK: ioctl SIOCGIFFLAGS on tun0 ==="); - int fd = socket(AF_INET, SOCK_DGRAM, 0); - if (fd < 0) { - std::string r = "FAIL: cannot create socket: " + std::string(strerror(errno)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - struct ifreq ifr{}; - strncpy(ifr.ifr_name, "tun0", IFNAMSIZ - 1); - - int ret = ioctl(fd, SIOCGIFFLAGS, &ifr); - int err = errno; - close(fd); - - std::string result; - if (ret < 0) { - if (err == ENODEV) { - result = "PASS: ioctl(tun0, SIOCGIFFLAGS) returned ENODEV — interface not visible"; - } else if (err == ENXIO) { - result = "PASS: ioctl(tun0, SIOCGIFFLAGS) returned ENXIO — interface not visible"; - } else { - result = "FAIL: ioctl returned error " + std::to_string(err) + " (" + strerror(err) + ")"; - } - } else { - result = "FAIL: tun0 is visible! flags=0x" + std::to_string(ifr.ifr_flags) + - " (IFF_UP=" + std::to_string(!!(ifr.ifr_flags & IFF_UP)) + - ", IFF_RUNNING=" + std::to_string(!!(ifr.ifr_flags & IFF_RUNNING)) + ")"; - } - LOGI("RESULT: %s", result.c_str()); - return to_jstring(env, result); -} - -// 2. ioctl SIOCGIFCONF - enumerate interfaces -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkIoctlSiocgifconf(JNIEnv* env, jobject) { - LOGI("=== CHECK: ioctl SIOCGIFCONF enumeration ==="); - int fd = socket(AF_INET, SOCK_DGRAM, 0); - if (fd < 0) { - std::string r = "FAIL: cannot create socket: " + std::string(strerror(errno)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - char buf[4096]; - struct ifconf ifc{}; - ifc.ifc_len = sizeof(buf); - ifc.ifc_buf = buf; - - if (ioctl(fd, SIOCGIFCONF, &ifc) < 0) { - int err = errno; - close(fd); - std::string r = "FAIL: ioctl error: " + std::string(strerror(err)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - close(fd); - - struct ifreq* it = ifc.ifc_req; - int count = ifc.ifc_len / sizeof(struct ifreq); - std::vector all_ifaces; - std::vector vpn_found; - - for (int i = 0; i < count; i++) { - const char* name = it[i].ifr_name; - all_ifaces.push_back(name); - LOGI(" SIOCGIFCONF: interface '%s'", name); - if (is_vpn_iface(name)) { - vpn_found.push_back(name); - } - } - - std::string all_list; - for (auto& n : all_ifaces) { if (!all_list.empty()) all_list += ", "; all_list += n; } - - std::string result; - if (vpn_found.empty()) { - result = "PASS: " + std::to_string(count) + " interfaces visible: [" + all_list + "], none are VPN"; - } else { - std::string vpn_list; - for (auto& n : vpn_found) { if (!vpn_list.empty()) vpn_list += ", "; vpn_list += n; } - result = "FAIL: VPN interfaces found: [" + vpn_list + "] in full list: [" + all_list + "]"; - } - LOGI("RESULT: %s", result.c_str()); - return to_jstring(env, result); -} - -// 3. getifaddrs -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkGetifaddrs(JNIEnv* env, jobject) { - LOGI("=== CHECK: getifaddrs() enumeration ==="); - struct ifaddrs* addrs = nullptr; - if (getifaddrs(&addrs) != 0) { - std::string r = "FAIL: getifaddrs error: " + std::string(strerror(errno)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - std::vector all_ifaces; - std::vector vpn_found; - for (struct ifaddrs* ifa = addrs; ifa; ifa = ifa->ifa_next) { - if (!ifa->ifa_name) continue; - // Deduplicate for display - bool seen = false; - for (auto& n : all_ifaces) if (n == ifa->ifa_name) { seen = true; break; } - if (!seen) { - all_ifaces.push_back(ifa->ifa_name); - LOGI(" getifaddrs: interface '%s' (family=%d, flags=0x%x)", - ifa->ifa_name, ifa->ifa_addr ? ifa->ifa_addr->sa_family : -1, ifa->ifa_flags); - } - if (is_vpn_iface(ifa->ifa_name)) { - bool dup = false; - for (auto& n : vpn_found) if (n == ifa->ifa_name) { dup = true; break; } - if (!dup) vpn_found.push_back(ifa->ifa_name); - } - } - freeifaddrs(addrs); - - std::string all_list; - for (auto& n : all_ifaces) { if (!all_list.empty()) all_list += ", "; all_list += n; } - - std::string result; - if (vpn_found.empty()) { - result = "PASS: " + std::to_string(all_ifaces.size()) + " unique interfaces: [" + all_list + "], none are VPN"; - } else { - std::string vpn_list; - for (auto& n : vpn_found) { if (!vpn_list.empty()) vpn_list += ", "; vpn_list += n; } - result = "FAIL: VPN interfaces: [" + vpn_list + "] in full list: [" + all_list + "]"; - } - LOGI("RESULT: %s", result.c_str()); - return to_jstring(env, result); -} - -// Helper: read a proc file and return detailed info -static std::string check_proc_file(const char* path) { - LOGI("=== CHECK: %s (native read) ===", path); - int fd = open(path, O_RDONLY); - if (fd < 0) { - if (errno == EACCES || errno == EPERM) { - std::string r = "PASS: access denied by SELinux (" + std::string(strerror(errno)) + ") — app cannot read " + path; - LOGI("RESULT: %s", r.c_str()); - return r; - } - std::string r = "FAIL: cannot open " + std::string(path) + ": " + strerror(errno); - LOGI("RESULT: %s", r.c_str()); - return r; - } - - char buf[8192]; - std::string content; - ssize_t n; - while ((n = read(fd, buf, sizeof(buf) - 1)) > 0) { - buf[n] = '\0'; - content += buf; - } - close(fd); - - int total_lines = 0; - std::vector vpn_lines; - size_t pos = 0; - while (pos < content.size()) { - size_t eol = content.find('\n', pos); - if (eol == std::string::npos) eol = content.size(); - std::string line = content.substr(pos, eol - pos); - pos = eol + 1; - if (line.empty()) continue; - total_lines++; - - LOGI(" %s line: %s", path, line.substr(0, 120).c_str()); - - for (int i = 0; i < NUM_PREFIXES; i++) { - if (line.find(VPN_PREFIXES[i]) != std::string::npos) { - vpn_lines.push_back(line.substr(0, 80)); - break; - } - } - } - - std::string result; - if (vpn_lines.empty()) { - result = "PASS: " + std::to_string(total_lines) + " lines in " + path + ", no VPN entries"; - } else { - result = "FAIL: " + std::to_string(vpn_lines.size()) + " VPN lines in " + path + ":"; - for (auto& l : vpn_lines) result += "\n " + l; - } - LOGI("RESULT: %s", result.c_str()); - return result; -} - -// 4. /proc/net/route -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetRoute(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/route")); -} - -// 5. /proc/net/if_inet6 -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetIfInet6(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/if_inet6")); -} - -// Helper: try to open and bind a netlink route socket. -// Returns fd on success, -1 on failure. Sets result string on SELinux denial. -static int open_netlink(std::string& err_result) { - int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); - if (fd < 0) { - if (errno == EACCES || errno == EPERM) { - err_result = "PASS: netlink socket denied by SELinux (" + std::string(strerror(errno)) + ")"; - } else { - err_result = "FAIL: cannot create netlink socket: " + std::string(strerror(errno)); - } - return -1; - } - - struct sockaddr_nl sa{}; - sa.nl_family = AF_NETLINK; - if (bind(fd, (struct sockaddr*)&sa, sizeof(sa)) < 0) { - int err = errno; - close(fd); - if (err == EACCES || err == EPERM) { - err_result = "PASS: netlink bind denied by SELinux (" + std::string(strerror(err)) + ")"; - } else { - err_result = "FAIL: bind error: " + std::string(strerror(err)); - } - return -1; - } - return fd; -} - -// 6. Netlink RTM_GETLINK -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkNetlinkGetlink(JNIEnv* env, jobject) { - LOGI("=== CHECK: netlink RTM_GETLINK dump ==="); - int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); - if (fd < 0) { - std::string r = "FAIL: cannot create netlink socket: " + std::string(strerror(errno)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - struct sockaddr_nl sa{}; - sa.nl_family = AF_NETLINK; - if (bind(fd, (struct sockaddr*)&sa, sizeof(sa)) < 0) { - int err = errno; - close(fd); - if (err == EACCES || err == EPERM) { - std::string r = "PASS: netlink bind denied by SELinux (" + std::string(strerror(err)) + ") — app cannot enumerate interfaces"; - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - std::string r = "FAIL: bind error: " + std::string(strerror(err)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - struct { - struct nlmsghdr nlh; - struct ifinfomsg ifm; - } req{}; - - req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); - req.nlh.nlmsg_type = RTM_GETLINK; - req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; - req.nlh.nlmsg_seq = 1; - req.ifm.ifi_family = AF_UNSPEC; - - if (send(fd, &req, req.nlh.nlmsg_len, 0) < 0) { - int err = errno; - close(fd); - std::string r = "FAIL: send error: " + std::string(strerror(err)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - char buf[32768]; - std::vector all_ifaces; - std::vector vpn_found; - bool done = false; - - while (!done) { - ssize_t len = recv(fd, buf, sizeof(buf), 0); - if (len <= 0) break; - - for (struct nlmsghdr* nh = (struct nlmsghdr*)buf; - NLMSG_OK(nh, (size_t)len); - nh = NLMSG_NEXT(nh, len)) { - - if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } - if (nh->nlmsg_type == NLMSG_ERROR) { done = true; break; } - if (nh->nlmsg_type != RTM_NEWLINK) continue; - - struct ifinfomsg* ifi = (struct ifinfomsg*)NLMSG_DATA(nh); - struct rtattr* rta = IFLA_RTA(ifi); - int rta_len = IFLA_PAYLOAD(nh); - - while (RTA_OK(rta, rta_len)) { - if (rta->rta_type == IFLA_IFNAME) { - const char* name = (const char*)RTA_DATA(rta); - all_ifaces.push_back(name); - LOGI(" netlink RTM_NEWLINK: interface '%s' (index=%d, flags=0x%x)", - name, ifi->ifi_index, ifi->ifi_flags); - if (is_vpn_iface(name)) { - vpn_found.push_back(name); - } - } - rta = RTA_NEXT(rta, rta_len); - } - } - } - close(fd); - - std::string all_list; - for (auto& n : all_ifaces) { if (!all_list.empty()) all_list += ", "; all_list += n; } - - std::string result; - if (vpn_found.empty()) { - result = "PASS: " + std::to_string(all_ifaces.size()) + " interfaces via netlink: [" + all_list + "], none are VPN"; - } else { - std::string vpn_list; - for (auto& n : vpn_found) { if (!vpn_list.empty()) vpn_list += ", "; vpn_list += n; } - result = "FAIL: VPN interfaces: [" + vpn_list + "] in netlink dump: [" + all_list + "]"; - } - LOGI("RESULT: %s", result.c_str()); - return to_jstring(env, result); -} - -// 7. netlink RTM_GETROUTE -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkNetlinkGetroute(JNIEnv* env, jobject) { - LOGI("=== CHECK: netlink RTM_GETROUTE dump ==="); - std::string err; - int fd = open_netlink(err); - if (fd < 0) { - LOGI("RESULT: %s", err.c_str()); - return to_jstring(env, err); - } - - struct { - struct nlmsghdr nlh; - struct rtmsg rtm; - } req{}; - req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); - req.nlh.nlmsg_type = RTM_GETROUTE; - req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; - req.nlh.nlmsg_seq = 1; - req.rtm.rtm_family = AF_UNSPEC; - - if (send(fd, &req, req.nlh.nlmsg_len, 0) < 0) { - int e = errno; - close(fd); - std::string r = "FAIL: send error: " + std::string(strerror(e)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - char buf[32768]; - std::vector vpn_found; - int total = 0; - bool done = false; - - while (!done) { - ssize_t len = recv(fd, buf, sizeof(buf), 0); - if (len <= 0) break; - for (struct nlmsghdr* nh = (struct nlmsghdr*)buf; - NLMSG_OK(nh, (size_t)len); nh = NLMSG_NEXT(nh, len)) { - if (nh->nlmsg_type == NLMSG_DONE) { done = true; break; } - if (nh->nlmsg_type == NLMSG_ERROR) { done = true; break; } - if (nh->nlmsg_type != RTM_NEWROUTE) continue; - total++; - struct rtmsg* rtm = (struct rtmsg*)NLMSG_DATA(nh); - struct rtattr* rta = RTM_RTA(rtm); - int rta_len = RTM_PAYLOAD(nh); - while (RTA_OK(rta, rta_len)) { - if (rta->rta_type == RTA_OIF) { - int ifindex = *(int*)RTA_DATA(rta); - char ifname[IFNAMSIZ]; - if (if_indextoname(ifindex, ifname) && is_vpn_iface(ifname)) { - vpn_found.push_back(ifname); - LOGI(" RTM_GETROUTE: VPN route via '%s'", ifname); - } - } - rta = RTA_NEXT(rta, rta_len); - } - } - } - close(fd); - - std::string result; - if (vpn_found.empty()) { - result = "PASS: " + std::to_string(total) + " routes, no VPN"; - } else { - std::string vl; - for (auto& n : vpn_found) { if (!vl.empty()) vl += ", "; vl += n; } - result = "FAIL: VPN routes via [" + vl + "]"; - } - LOGI("RESULT: %s", result.c_str()); - return to_jstring(env, result); -} - -// 8. /proc/net/ipv6_route -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetIpv6Route(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/ipv6_route")); -} - -// 9. /proc/net/tcp -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetTcp(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/tcp")); -} - -// 10. /proc/net/tcp6 -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetTcp6(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/tcp6")); -} - -// 11. /proc/net/udp -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetUdp(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/udp")); -} - -// 12. /proc/net/udp6 -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetUdp6(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/udp6")); -} - -// 13. /proc/net/dev -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetDev(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/dev")); -} - -// 14. /proc/net/fib_trie -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetFibTrie(JNIEnv* env, jobject) { - return to_jstring(env, check_proc_file("/proc/net/fib_trie")); -} - -// 15. /sys/class/net — check for VPN interface directories -extern "C" JNIEXPORT jstring JNICALL -Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkSysClassNet(JNIEnv* env, jobject) { - LOGI("=== CHECK: /sys/class/net/ directory ==="); - DIR* dir = opendir("/sys/class/net"); - if (!dir) { - int err = errno; - if (err == EACCES || err == EPERM) { - std::string r = "PASS: access denied by SELinux (" + std::string(strerror(err)) + ")"; - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - std::string r = "FAIL: cannot open /sys/class/net: " + std::string(strerror(err)); - LOGI("RESULT: %s", r.c_str()); - return to_jstring(env, r); - } - - std::vector all_ifaces; - std::vector vpn_found; - struct dirent* entry; - while ((entry = readdir(dir)) != nullptr) { - if (entry->d_name[0] == '.') continue; - all_ifaces.push_back(entry->d_name); - LOGI(" /sys/class/net: '%s'", entry->d_name); - if (is_vpn_iface(entry->d_name)) { - vpn_found.push_back(entry->d_name); - } - } - closedir(dir); - - std::string all_list; - for (auto& n : all_ifaces) { if (!all_list.empty()) all_list += ", "; all_list += n; } - - std::string result; - if (vpn_found.empty()) { - result = "PASS: [" + all_list + "], no VPN"; - } else { - std::string vl; - for (auto& n : vpn_found) { if (!vl.empty()) vl += ", "; vl += n; } - result = "FAIL: VPN interfaces [" + vl + "] in [" + all_list + "]"; - } - LOGI("RESULT: %s", result.c_str()); - return to_jstring(env, result); -} diff --git a/test-app/build-native.sh b/test-app/build-native.sh new file mode 100755 index 0000000..d6e4a85 --- /dev/null +++ b/test-app/build-native.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the Rust native library for the test app and copy it to jniLibs. +set -euo pipefail + +cd "$(dirname "$0")/native" + +# Auto-detect NDK +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + ANDROID_NDK_HOME="$(find "$HOME/Android/Sdk/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1)" +fi +export ANDROID_NDK_HOME + +cargo ndk -t arm64-v8a build --release + +SO="target/aarch64-linux-android/release/libvpnhide_test.so" +DEST="../app/src/main/jniLibs/arm64-v8a" +mkdir -p "$DEST" +cp "$SO" "$DEST/" + +echo "Copied $(ls -lh "$DEST/libvpnhide_test.so" | awk '{print $5}') to $DEST/" diff --git a/test-app/native/Cargo.lock b/test-app/native/Cargo.lock new file mode 100644 index 0000000..478c67c --- /dev/null +++ b/test-app/native/Cargo.lock @@ -0,0 +1,293 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vpnhide_test" +version = "0.1.0" +dependencies = [ + "android_logger", + "jni", + "libc", + "log", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" diff --git a/test-app/native/Cargo.toml b/test-app/native/Cargo.toml new file mode 100644 index 0000000..a434d8b --- /dev/null +++ b/test-app/native/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vpnhide_test" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "MIT" + +[lib] +name = "vpnhide_test" +crate-type = ["cdylib"] + +[dependencies] +jni = { version = "0.21", default-features = false } +libc = "0.2" +log = "0.4" +android_logger = { version = "0.15", default-features = false } + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +strip = true +panic = "abort" + +[profile.dev] +panic = "abort" diff --git a/test-app/native/src/lib.rs b/test-app/native/src/lib.rs new file mode 100644 index 0000000..d311410 --- /dev/null +++ b/test-app/native/src/lib.rs @@ -0,0 +1,501 @@ +use jni::objects::JClass; +use jni::sys::jstring; +use jni::JNIEnv; +use std::ffi::CStr; +use std::io::ErrorKind; + +const VPN_PREFIXES: &[&str] = &["tun", "wg", "ppp", "tap", "ipsec", "xfrm"]; + +fn is_vpn_iface(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + VPN_PREFIXES.iter().any(|p| n.starts_with(p)) || n.contains("vpn") +} + +fn logi(msg: &str) { + log::info!("{msg}"); +} + +fn result_to_jstring(env: &mut JNIEnv, s: &str) -> jstring { + env.new_string(s) + .map(|j| j.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +fn is_selinux_denial(e: &std::io::Error) -> bool { + e.kind() == ErrorKind::PermissionDenied +} + +// ── helpers ────────────────────────────────────────────────────────── + +fn cstr_to_str(ptr: *const libc::c_char) -> String { + if ptr.is_null() { + return String::new(); + } + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() +} + +fn last_os_error() -> String { + std::io::Error::last_os_error().to_string() +} + +fn last_os_errno() -> i32 { + std::io::Error::last_os_error().raw_os_error().unwrap_or(0) +} + +fn join_list(v: &[String]) -> String { + v.join(", ") +} + +fn format_iface_result(all: &[String], vpn: &[String], context: &str) -> String { + if vpn.is_empty() { + format!("PASS: {context} [{list}], no VPN", list = join_list(all)) + } else { + format!( + "FAIL: VPN interfaces [{vpn}] in [{all}]", + vpn = join_list(vpn), + all = join_list(all), + ) + } +} + +// ── structs missing from libc crate on Android ─────────────────────── + +#[repr(C)] +struct Ifinfomsg { + ifi_family: u8, + _pad: u8, + ifi_type: u16, + ifi_index: i32, + ifi_flags: u32, + ifi_change: u32, +} + +#[repr(C)] +struct Rtmsg { + rtm_family: u8, + rtm_dst_len: u8, + rtm_src_len: u8, + rtm_tos: u8, + rtm_table: u8, + rtm_protocol: u8, + rtm_scope: u8, + rtm_type: u8, + rtm_flags: u32, +} + +#[repr(C)] +struct Rtattr { + rta_len: u16, + rta_type: u16, +} + +const IFLA_IFNAME: u16 = 3; +const RTA_OIF: u16 = 4; + +// ── check implementations ──────────────────────────────────────────── + +fn check_ioctl_siocgifflags() -> String { + logi("=== CHECK: ioctl SIOCGIFFLAGS on tun0 ==="); + unsafe { + let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0); + if fd < 0 { + return format!("FAIL: cannot create socket: {}", last_os_error()); + } + + let mut ifr: libc::ifreq = std::mem::zeroed(); + let name = b"tun0\0"; + ifr.ifr_name[..name.len()].copy_from_slice(&name.map(|b| b as libc::c_char)); + + let ret = libc::ioctl(fd, libc::SIOCGIFFLAGS as i32, &ifr); + let err = last_os_errno(); + libc::close(fd); + + if ret < 0 { + if err == libc::ENODEV { + "PASS: ioctl(tun0, SIOCGIFFLAGS) returned ENODEV — interface not visible".into() + } else if err == libc::ENXIO { + "PASS: ioctl(tun0, SIOCGIFFLAGS) returned ENXIO — interface not visible".into() + } else { + format!("FAIL: ioctl returned error {err} ({})", last_os_error()) + } + } else { + let flags = ifr.ifr_ifru.ifru_flags as u32; + format!( + "FAIL: tun0 is visible! flags=0x{flags:x} (IFF_UP={}, IFF_RUNNING={})", + u8::from(flags & libc::IFF_UP as u32 != 0), + u8::from(flags & libc::IFF_RUNNING as u32 != 0), + ) + } + } +} + +fn check_ioctl_siocgifconf() -> String { + logi("=== CHECK: ioctl SIOCGIFCONF enumeration ==="); + unsafe { + let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0); + if fd < 0 { + return format!("FAIL: cannot create socket: {}", last_os_error()); + } + + let mut buf = [0u8; 4096]; + let mut ifc: libc::ifconf = std::mem::zeroed(); + ifc.ifc_len = buf.len() as libc::c_int; + ifc.ifc_ifcu.ifcu_buf = buf.as_mut_ptr().cast(); + + if libc::ioctl(fd, libc::SIOCGIFCONF as i32, &mut ifc) < 0 { + let e = last_os_error(); + libc::close(fd); + return format!("FAIL: ioctl error: {e}"); + } + libc::close(fd); + + let count = ifc.ifc_len as usize / std::mem::size_of::(); + let reqs = std::slice::from_raw_parts(buf.as_ptr() as *const libc::ifreq, count); + + let mut all = Vec::new(); + let mut vpn = Vec::new(); + for req in reqs { + let name = cstr_to_str(req.ifr_name.as_ptr()); + logi(&format!(" SIOCGIFCONF: interface '{name}'")); + if is_vpn_iface(&name) { + vpn.push(name.clone()); + } + all.push(name); + } + + format_iface_result(&all, &vpn, &format!("{count} interfaces visible:")) + } +} + +fn check_getifaddrs() -> String { + logi("=== CHECK: getifaddrs() enumeration ==="); + unsafe { + let mut addrs: *mut libc::ifaddrs = std::ptr::null_mut(); + if libc::getifaddrs(&mut addrs) != 0 { + return format!("FAIL: getifaddrs error: {}", last_os_error()); + } + + let mut all: Vec = Vec::new(); + let mut vpn: Vec = Vec::new(); + let mut ifa = addrs; + while !ifa.is_null() { + let entry = &*ifa; + if !entry.ifa_name.is_null() { + let name = cstr_to_str(entry.ifa_name); + if !all.contains(&name) { + let family = if entry.ifa_addr.is_null() { + -1 + } else { + i32::from((*entry.ifa_addr).sa_family) + }; + logi(&format!( + " getifaddrs: interface '{name}' (family={family}, flags=0x{:x})", + entry.ifa_flags + )); + all.push(name.clone()); + } + if is_vpn_iface(&name) && !vpn.contains(&name) { + vpn.push(name); + } + } + ifa = entry.ifa_next; + } + libc::freeifaddrs(addrs); + + format_iface_result(&all, &vpn, &format!("{} unique interfaces:", all.len())) + } +} + +fn check_proc_file(path: &str) -> String { + logi(&format!("=== CHECK: {path} (native read) ===")); + match std::fs::read_to_string(path) { + Err(e) => { + if is_selinux_denial(&e) { + return format!("PASS: access denied by SELinux ({e}) — app cannot read {path}"); + } + format!("FAIL: cannot open {path}: {e}") + } + Ok(content) => { + let mut total = 0; + let mut vpn_lines = Vec::new(); + for line in content.lines() { + if line.is_empty() { + continue; + } + total += 1; + logi(&format!(" {path} line: {}", &line[..line.len().min(120)])); + if VPN_PREFIXES.iter().any(|p| line.contains(p)) { + vpn_lines.push(line[..line.len().min(80)].to_string()); + } + } + if vpn_lines.is_empty() { + format!("PASS: {total} lines in {path}, no VPN entries") + } else { + let details: String = vpn_lines.iter().map(|l| format!("\n {l}")).collect(); + format!("FAIL: {} VPN lines in {path}:{details}", vpn_lines.len()) + } + } + } +} + +fn open_netlink() -> Result { + unsafe { + let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, libc::NETLINK_ROUTE); + if fd < 0 { + let e = std::io::Error::last_os_error(); + return Err(if is_selinux_denial(&e) { + format!("PASS: netlink socket denied by SELinux ({e})") + } else { + format!("FAIL: cannot create netlink socket: {e}") + }); + } + + let mut sa: libc::sockaddr_nl = std::mem::zeroed(); + sa.nl_family = libc::AF_NETLINK as u16; + let sa_len = std::mem::size_of_val(&sa) as libc::socklen_t; + if libc::bind(fd, std::ptr::from_ref(&sa).cast(), sa_len) < 0 { + let e = std::io::Error::last_os_error(); + libc::close(fd); + return Err(if is_selinux_denial(&e) { + format!("PASS: netlink bind denied by SELinux ({e}) — app cannot enumerate interfaces") + } else { + format!("FAIL: bind error: {e}") + }); + } + Ok(fd) + } +} + +/// Parse netlink messages from a buffer, calling `on_msg` for each message. +/// Returns false if NLMSG_DONE or NLMSG_ERROR was seen. +/// +/// # Safety +/// `buf` must contain valid netlink messages up to `len` bytes. +unsafe fn parse_netlink_msgs( + buf: &[u8], + len: usize, + msg_type: u16, + mut on_msg: impl FnMut(&[u8], usize, usize), +) -> bool { + let mut offset = 0usize; + let hdr_size = std::mem::size_of::(); + while offset + hdr_size <= len { + let nh = unsafe { &*(buf.as_ptr().add(offset) as *const libc::nlmsghdr) }; + let msg_len = nh.nlmsg_len as usize; + if msg_len < hdr_size || msg_len > len - offset { + break; + } + if nh.nlmsg_type == libc::NLMSG_DONE as u16 || nh.nlmsg_type == libc::NLMSG_ERROR as u16 { + return false; + } + if nh.nlmsg_type == msg_type { + on_msg(buf, offset, msg_len); + } + offset += (msg_len + 3) & !3; + } + true // continue receiving +} + +/// Iterate rtattr entries within a netlink message payload. +/// +/// # Safety +/// `buf[start..end]` must contain valid rtattr entries. +unsafe fn for_each_rtattr(buf: &[u8], start: usize, end: usize, mut on_attr: impl FnMut(&Rtattr, usize)) { + let mut off = start; + while off + 4 <= end { + let rta = unsafe { &*(buf.as_ptr().add(off) as *const Rtattr) }; + if rta.rta_len < 4 { + break; + } + on_attr(rta, off); + off += (rta.rta_len as usize + 3) & !3; + } +} + +fn check_netlink_getlink() -> String { + logi("=== CHECK: netlink RTM_GETLINK dump ==="); + let fd = match open_netlink() { + Ok(fd) => fd, + Err(msg) => return msg, + }; + + unsafe { + #[repr(C)] + struct Req { + nlh: libc::nlmsghdr, + ifm: Ifinfomsg, + } + let mut req: Req = std::mem::zeroed(); + req.nlh.nlmsg_len = std::mem::size_of::() as u32; + req.nlh.nlmsg_type = libc::RTM_GETLINK; + req.nlh.nlmsg_flags = (libc::NLM_F_REQUEST | libc::NLM_F_DUMP) as u16; + req.nlh.nlmsg_seq = 1; + + if libc::send(fd, std::ptr::from_ref(&req).cast(), req.nlh.nlmsg_len as usize, 0) < 0 { + let e = last_os_error(); + libc::close(fd); + return format!("FAIL: send error: {e}"); + } + + let mut buf = [0u8; 32768]; + let mut all = Vec::new(); + let mut vpn = Vec::new(); + let hdr_plus_ifinfo = + std::mem::size_of::() + std::mem::size_of::(); + + loop { + let len = libc::recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0); + if len <= 0 { + break; + } + let cont = parse_netlink_msgs(&buf, len as usize, libc::RTM_NEWLINK, |b, offset, msg_len| { + let data_start = offset + hdr_plus_ifinfo; + let msg_end = offset + msg_len; + for_each_rtattr(b, data_start, msg_end, |rta, rta_off| { + if rta.rta_type == IFLA_IFNAME { + let name = cstr_to_str(b.as_ptr().add(rta_off + 4) as *const libc::c_char); + logi(&format!(" netlink RTM_NEWLINK: interface '{name}'")); + if is_vpn_iface(&name) { + vpn.push(name.clone()); + } + all.push(name); + } + }); + }); + if !cont { + break; + } + } + libc::close(fd); + + format_iface_result(&all, &vpn, &format!("{} interfaces via netlink:", all.len())) + } +} + +fn check_netlink_getroute() -> String { + logi("=== CHECK: netlink RTM_GETROUTE dump ==="); + let fd = match open_netlink() { + Ok(fd) => fd, + Err(msg) => return msg, + }; + + unsafe { + #[repr(C)] + struct Req { + nlh: libc::nlmsghdr, + rtm: Rtmsg, + } + let mut req: Req = std::mem::zeroed(); + req.nlh.nlmsg_len = std::mem::size_of::() as u32; + req.nlh.nlmsg_type = libc::RTM_GETROUTE; + req.nlh.nlmsg_flags = (libc::NLM_F_REQUEST | libc::NLM_F_DUMP) as u16; + req.nlh.nlmsg_seq = 1; + + if libc::send(fd, std::ptr::from_ref(&req).cast(), req.nlh.nlmsg_len as usize, 0) < 0 { + let e = last_os_error(); + libc::close(fd); + return format!("FAIL: send error: {e}"); + } + + let mut buf = [0u8; 32768]; + let mut vpn = Vec::new(); + let mut total = 0u32; + let hdr_plus_rtmsg = + std::mem::size_of::() + std::mem::size_of::(); + + loop { + let len = libc::recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0); + if len <= 0 { + break; + } + let cont = parse_netlink_msgs(&buf, len as usize, libc::RTM_NEWROUTE, |b, offset, msg_len| { + total += 1; + let data_start = offset + hdr_plus_rtmsg; + let msg_end = offset + msg_len; + for_each_rtattr(b, data_start, msg_end, |rta, rta_off| { + if rta.rta_type == RTA_OIF { + let ifindex = *(b.as_ptr().add(rta_off + 4) as *const i32); + let mut ifname_buf = [0u8; libc::IF_NAMESIZE]; + let ptr = libc::if_indextoname(ifindex as u32, ifname_buf.as_mut_ptr().cast()); + if !ptr.is_null() { + let name = cstr_to_str(ptr); + if is_vpn_iface(&name) { + logi(&format!(" RTM_GETROUTE: VPN route via '{name}'")); + vpn.push(name); + } + } + } + }); + }); + if !cont { + break; + } + } + libc::close(fd); + + if vpn.is_empty() { + format!("PASS: {total} routes, no VPN") + } else { + format!("FAIL: VPN routes via [{}]", join_list(&vpn)) + } + } +} + +fn check_sys_class_net() -> String { + logi("=== CHECK: /sys/class/net/ directory ==="); + match std::fs::read_dir("/sys/class/net") { + Err(e) => { + if is_selinux_denial(&e) { + format!("PASS: access denied by SELinux ({e})") + } else { + format!("FAIL: cannot open /sys/class/net: {e}") + } + } + Ok(entries) => { + let mut all = Vec::new(); + let mut vpn = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + logi(&format!(" /sys/class/net: '{name}'")); + if is_vpn_iface(&name) { + vpn.push(name.clone()); + } + all.push(name); + } + format_iface_result(&all, &vpn, &format!("[{}]:", all.len())) + } + } +} + +// ── JNI exports ────────────────────────────────────────────────────── + +// ── JNI exports ────────────────────────────────────────────────────── + +macro_rules! jni_fn { + ($name:ident, $body:expr) => { + #[unsafe(no_mangle)] + pub extern "system" fn $name(mut env: JNIEnv, _class: JClass) -> jstring { + let result = $body; + logi(&format!("RESULT: {result}")); + result_to_jstring(&mut env, &result) + } + }; +} + +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkIoctlSiocgifflags, check_ioctl_siocgifflags()); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkIoctlSiocgifconf, check_ioctl_siocgifconf()); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkGetifaddrs, check_getifaddrs()); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetRoute, check_proc_file("/proc/net/route")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetIfInet6, check_proc_file("/proc/net/if_inet6")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkNetlinkGetlink, check_netlink_getlink()); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkNetlinkGetroute, check_netlink_getroute()); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetIpv6Route, check_proc_file("/proc/net/ipv6_route")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetTcp, check_proc_file("/proc/net/tcp")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetTcp6, check_proc_file("/proc/net/tcp6")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetUdp, check_proc_file("/proc/net/udp")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetUdp6, check_proc_file("/proc/net/udp6")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetDev, check_proc_file("/proc/net/dev")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkProcNetFibTrie, check_proc_file("/proc/net/fib_trie")); +jni_fn!(Java_dev_okhsunrog_vpnhide_test_NativeChecks_checkSysClassNet, check_sys_class_net());