refactor: replace C++ native checks with Rust

Port all 15 native VPN detection checks from C++ to Rust using
jni + libc crates. Gradle triggers cargo-ndk automatically via
a preBuild dependency — single `./gradlew installDebug` builds
everything.

- Remove CMakeLists.txt and native-lib.cpp
- Add test-app/native/ Rust crate with Cargo.toml and src/lib.rs
- Gradle buildRustNative task runs cargo-ndk, copies .so to jniLibs
- Update CI test-app job with Rust + NDK setup
- 23/23 checks pass on device
This commit is contained in:
okhsunrog 2026-04-11 22:19:59 +03:00
parent 5eaebd0a12
commit e413debe45
9 changed files with 880 additions and 550 deletions

501
test-app/native/src/lib.rs Normal file
View file

@ -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::<libc::ifreq>();
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<String> = Vec::new();
let mut vpn: Vec<String> = 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<i32, String> {
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::<libc::nlmsghdr>();
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::<Req>() 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::<libc::nlmsghdr>() + std::mem::size_of::<Ifinfomsg>();
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::<Req>() 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::<libc::nlmsghdr>() + std::mem::size_of::<Rtmsg>();
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());