From e83dba94a09808c5468572db212832b62e5599fe Mon Sep 17 00:00:00 2001 From: Aliza Date: Sun, 28 Jun 2026 06:33:36 +0330 Subject: [PATCH] feat: add a root, system-wide run mode without VpnService (#5812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add a root, system-wide run mode without VpnService Adds an optional Root mode for rooted devices that routes the whole device's traffic through the existing in-process core without Android's VpnService, plus an opt-in LAN/tethering sharing feature. Non-root devices are unaffected and keep VPN / Proxy-only (VPN stays the default). - ERunMode (VPN, PROXY_ONLY, TUN2SOCKS) persisted in the existing PREF_MODE; RootManager gates root modes (greyed-out for non-root, service refuses to start). - CoreRootService + core/root/RootProxyManager run hev-socks5-tunnel as a standalone root process into the core's SOCKS inbound, steered by an iptables mangle MARK chain + a dedicated route table. Full TCP + UDP. hev-socks5-tunnel is the same engine already bundled for the VPN hev path, so no new third-party dependency is added. - Capture parity with VpnService incl. per-app proxy/bypass; DNS funneled into the core (netd-aware, no uid filter) so names resolve through the configured resolver with no LAN-resolver leak. - IPv6 parity: routed into the tun when enabled, otherwise native v6 is blackholed for the captured apps (REJECT) so they fall back to v4-through-proxy, like a v4-only VpnService. - MTU taken from the existing VPN MTU setting; hev tun multi-queue + SOCKS tcp-fastopen enabled. - CI fetches the hev-socks5-tunnel binary per-ABI from heiher/hev-socks5-tunnel releases (the same upstream the VPN hev path uses). * build: compile libhevsockstun.so from source instead of downloading Build the standalone hev-socks5-tunnel binary used by Root mode from the pinned hev-socks5-tunnel submodule in compile-hevtun.sh, alongside the existing JNI shared library, and drop the prebuilt release download from the build workflow. Both hev artifacts now come from the same in-tree source, so the binary is fully auditable and version-locked to the submodule rather than a fetched release asset. The executable is built without -DENABLE_LIBRARY (so hev-main.c's main() is included) via BUILD_EXECUTABLE, reusing the NDK toolchain already used for the JNI library. * refactor(root): address review feedback - LAN-sharing guard now checks the cheap, usually-false PREF_ROOT_LAN_SHARING preference before RootManager.cachedRoot(), so the common path short-circuits without touching root state. - Move RootManager into the core.root package next to RootProxyManager and RootShell (CoreRootService stays under service/). - Probe su only when the user opts into a root feature — selecting a root mode or enabling LAN sharing — instead of automatically on every Settings open. If root is denied the selection is reverted with a toast. This avoids an unsolicited root-grant prompt for the common non-root case; root mode for a persisted selection is still re-verified when the service starts. * refactor(root): use coroutines instead of Thread for su probing Replace raw Thread usage in the root path with kotlinx coroutines, as requested in review. RootManager.refreshAsync (a callback + daemon Thread) becomes a suspending refresh() that runs the blocking su probe on Dispatchers.IO and returns the result. Callers updated accordingly: - SettingsActivity probes on demand via lifecycleScope.launch and updates the UI directly on resume (no manual runOnUiThread). - CoreVpnService starts the LAN-sharing client over CoroutineScope(IO) instead of a daemon Thread. * fix(root): don't capture all apps when per-app proxy resolves no uids In allow (proxy-only) mode the mangle/v6 builders fell through to the catch-all "mark/reject everything" branch whenever selectedUids was empty. That is a fail-open: if the selected packages momentarily fail to resolve to uids (e.g. at early boot, before PackageManager is ready), every unselected app gets tunneled instead of none — a privacy leak and the cause of per-app "proxying everything" after a reboot. Gate the catch-all on the mode itself (all-apps or bypass) rather than on "selected list happened to be non-empty". In allow mode mark only the resolved uids; if none resolved, mark nothing (fail closed). Mirror the same fix in the IPv6 blackhole chain. * fix(root): wait for async rule setup before teardown on stop CoreRootService/CoreVpnService post the foreground notification as soon as the core starts but install the root routing rules in a launched coroutine, which can take seconds (the setup script waits for the tun device to appear). If the user stops the service during that window, onDestroy/stopAllService ran the synchronous teardown first and the still-running setup then re-installed the rules and tun afterwards — leaving orphan routing rules and a tun forwarding into a now-dead core, which blackholes all traffic until the next start/stop cycle clears it (the "disconnect from the notification kills the internet, reconnect+disconnect to fix it" bug). Track the setup job and cancelAndJoin it before tearing down so teardown always runs last and removes everything the setup installed. * Adjust root package and add RootLanSharing object * Remove ERunMode , add PREF_ROOT_MODE_ENABLE * fix(root): handle tethered clients' IPv6 in LAN sharing to stop leaks LAN/tethering sharing only set up IPv4 forwarding for clients, so a hotspot/USB-tethered client with a native (RA-assigned) global IPv6 egressed the upstream interface directly, bypassing the proxy — an IPv6 leak. buildLanShareSetup now handles forwarded clients' v6: - IPv6 enabled: route it through the tun. A mangle PREROUTING chain marks non-LOCAL-sourced (forwarded) v6 into the tun route table, keeps loopback/link-local/ULA/multicast direct, and hijacks client DNS; FORWARD accepts traffic to/from the tun. A trailing REJECT fails closed so anything not marked into the tun (e.g. addrtype match unavailable) is dropped instead of leaked. - IPv6 disabled: REJECT all forwarded v6 (the device's own v6 is already blackholed in OUTPUT). Teardown drops the two new ip6tables chains; the v6 route/rule into the tun table were already cleaned. Ported from vincentng295/Magic_V2Ray cae4f7f. * Update build.gradle.kts * Adjust settings --------- Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com> --- V2rayNG/app/src/main/AndroidManifest.xml | 11 + .../src/main/java/com/v2ray/ang/AppConfig.kt | 23 + .../com/v2ray/ang/core/CoreConfigManager.kt | 38 +- .../com/v2ray/ang/core/CoreServiceManager.kt | 15 +- .../com/v2ray/ang/handler/SettingsManager.kt | 7 + .../java/com/v2ray/ang/root/RootLanSharing.kt | 50 ++ .../java/com/v2ray/ang/root/RootManager.kt | 62 +++ .../com/v2ray/ang/root/RootProxyManager.kt | 466 ++++++++++++++++++ .../main/java/com/v2ray/ang/root/RootShell.kt | 54 ++ .../com/v2ray/ang/service/CoreRootService.kt | 96 ++++ .../com/v2ray/ang/service/CoreVpnService.kt | 6 + .../java/com/v2ray/ang/ui/SettingsActivity.kt | 43 ++ .../src/main/res/values-zh-rCN/strings.xml | 7 + .../src/main/res/values-zh-rTW/strings.xml | 7 + V2rayNG/app/src/main/res/values/strings.xml | 6 + .../app/src/main/res/xml/pref_settings.xml | 17 + compile-hevtun.sh | 70 ++- 17 files changed, 970 insertions(+), 8 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/root/RootLanSharing.kt create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/root/RootManager.kt create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProxyManager.kt create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml index 4d150c65..69651a3a 100644 --- a/V2rayNG/app/src/main/AndroidManifest.xml +++ b/V2rayNG/app/src/main/AndroidManifest.xml @@ -201,6 +201,17 @@ android:value="proxy" /> + + + + /proc/$corePid/oom_score_adj 2>/dev/null; sleep 5; done' >/dev/null 2>&1 &") + appendLine("echo \$! > '$oomGuardPid'") + // tun device node + appendLine("if [ ! -e /dev/net/tun ]; then mkdir -p /dev/net; mknod /dev/net/tun c 10 200; chmod 666 /dev/net/tun; fi") + // hev-socks5-tunnel config: it creates the tun ($TUN) itself and forwards it to the + // in-process core's SOCKS inbound on loopback. MTU comes from the existing VPN MTU + // setting. No fwmark on hev's sockets: its only upstream connection is to 127.0.0.1 + // (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound + // runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop. + appendLine("cat > '$cfgFile' <<'HEVCFG'") + append(buildHevConfig(port, ipv6)) + appendLine("HEVCFG") + appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &") + appendLine("T2S_PID=\$!") + appendLine("echo \$T2S_PID > '$pidFile'") + appendLine("echo ${AppConfig.ROOT_OOM_SCORE} > /proc/\$T2S_PID/oom_score_adj 2>/dev/null || true") + // wait for the interface hev creates to appear + appendLine("i=0; while [ \$i -lt 20 ]; do ip link show $TUN >/dev/null 2>&1 && break; sleep 0.3; i=\$((i+1)); done") + appendLine("ip link show $TUN >/dev/null 2>&1 || { echo 'tun device did not come up'; cat '$logFile' 2>/dev/null; exit 1; }") + // relax reverse-path filtering for the tun + appendLine("echo 0 > /proc/sys/net/ipv4/conf/$TUN/rp_filter 2>/dev/null || true") + appendLine("echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter 2>/dev/null || true") + // address + default route in a dedicated table + appendLine("ip addr add ${AppConfig.ROOT_TUN_ADDR_V4} dev $TUN 2>/dev/null || true") + appendLine("ip link set dev $TUN up") + appendLine("ip route replace default dev $TUN table $TABLE") + appendLine("ip rule add fwmark $MARK table $TABLE priority $PRIORITY") + // mark the device's own packets into the tun (Root mode only) + if (captureDeviceTraffic) { + append(buildMangleMarking("iptables", appUid, perAppEnabled, bypassApps, selectedUids)) + } + // optionally route hotspot / USB-tethered clients through the tun too + if (lanShare) { + append(buildLanShareSetup(captureDeviceTraffic, ipv6)) + } + if (captureDeviceTraffic) { + // IPv6 is best-effort: never fail the (working) IPv4 setup over it. + appendLine("set +e") + if (ipv6) { + // route the device's v6 into the tun, same as v4 + appendLine("ip -6 addr add ${AppConfig.ROOT_TUN_ADDR_V6} dev $TUN 2>/dev/null || true") + appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true") + appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true") + append(buildMangleMarking("ip6tables", appUid, perAppEnabled, bypassApps, selectedUids)) + } else { + // v6 disabled: blackhole native v6 egress for the captured apps so they + // fall back to v4-through-proxy, matching what a v4-only VpnService does. + append(buildV6Blackhole(appUid, perAppEnabled, bypassApps, selectedUids)) + } + } + } + } + + /** + * hev-socks5-tunnel YAML config. hev creates the tun device named [TUN] itself, assigns it + * the tun addresses, and forwards everything it receives to the core's SOCKS inbound on + * loopback (TCP + UDP). MTU is taken from the existing VPN MTU setting. v6 is only given a + * tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by + * the v6 route into [TABLE]. + */ + private fun buildHevConfig(socksPort: Int, ipv6: Boolean): String { + val v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/") + val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/") + return buildString { + appendLine("tunnel:") + appendLine(" name: '$TUN'") + appendLine(" mtu: ${SettingsManager.getVpnMtu()}") + appendLine(" multi-queue: true") + appendLine(" ipv4: '$v4'") + if (ipv6) appendLine(" ipv6: '$v6'") + appendLine("socks5:") + appendLine(" port: $socksPort") + appendLine(" address: '${AppConfig.LOOPBACK}'") + appendLine(" udp: 'udp'") + appendLine(" tcp-fastopen: true") + } + } + + /** + * mangle OUTPUT marking chain (ipv4/ipv6). Mirrors VpnService's capture behavior: + * - all-apps (no per-app): mark EVERY remaining uid (incl uid 0 + all system uids), so + * nothing is missed; + * - bypass mode: the selected apps go fully direct, everything else is captured; + * - proxy mode: only the selected apps are captured. + */ + private fun buildMangleMarking( + cmd: String, + appUid: Int, + perAppEnabled: Boolean, + bypassApps: Boolean, + selectedUids: List, + ): String { + val allowMode = perAppEnabled && !bypassApps + val bypassSelected = perAppEnabled && bypassApps && selectedUids.isNotEmpty() + return buildString { + appendLine("$cmd -t mangle -N $CHAIN 2>/dev/null || true") + appendLine("$cmd -t mangle -F $CHAIN") + // the app's own core traffic (the real outbound) must not loop back into the tun. + // The $FWMARK RETURN is kept defensively (hev itself only talks to loopback, which + // the 127.0.0.0/8 bypass below already RETURNs). + appendLine("$cmd -t mangle -A $CHAIN -m mark --mark $FWMARK -j RETURN") + appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $appUid -j RETURN") + // bypass mode: selected apps go fully direct (incl their DNS) + if (bypassSelected) { + selectedUids.forEach { appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $it -j RETURN") } + } + // Route DNS through the core for ALL modes, with no uid filter. On Android the + // DNS query is sent by netd (a shared system uid) on behalf of the app, not under + // the app's own uid, so it can't be attributed to a selected uid via owner-match. + // This MUST also run before the LAN-bypass RETURNs below, otherwise a query to a + // LAN/router resolver (192.168.x / 10.x) would be returned direct and resolved by + // the local ISP resolver (DNS leak + CDN mis-resolution, e.g. Instagram media). + // The MARK survives a later RETURN, so the marked query still routes into the tun. + appendLine("$cmd -t mangle -A $CHAIN -p udp --dport 53 -j MARK --set-xmark $MARK") + appendLine("$cmd -t mangle -A $CHAIN -p tcp --dport 53 -j MARK --set-xmark $MARK") + // keep LAN / private destinations direct (per-family CIDR list) + val cidrs = if (cmd == "ip6tables") bypassCidrsV6 else bypassCidrs + cidrs.forEach { appendLine("$cmd -t mangle -A $CHAIN -d $it -j RETURN") } + if (allowMode) { + // Proxy ONLY the explicitly selected apps. If nothing resolved (e.g. the + // selected packages failed to resolve to uids at early boot), mark nothing + // instead of falling through to the catch-all below: a fail-open here would + // tunnel every unselected app — both a privacy leak and the "per-app proxies + // everything after a reboot" bug. + selectedUids.forEach { appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $it -j MARK --set-xmark $MARK") } + } else { + // all-apps mode (per-app off) or bypass mode: capture EVERY remaining uid + // (incl uid 0 + system uids) + appendLine("$cmd -t mangle -A $CHAIN -j MARK --set-xmark $MARK") + } + appendLine("$cmd -t mangle -D OUTPUT -j $CHAIN 2>/dev/null || true") + appendLine("$cmd -t mangle -A OUTPUT -j $CHAIN") + } + } + + /** + * Blackhole native IPv6 egress for the captured app population when IPv6 is NOT routed + * into the tun. A v4-only VpnService has no v6 route, so the kernel rejects apps' v6 and + * they fall back to IPv4; Root mode has to reproduce that explicitly, otherwise v6-capable + * apps reach destinations natively, bypassing the proxy / leaking. REJECT (not DROP) gives + * an instant failure so happy-eyeballs falls back to v4 without a timeout. + * + * Exemptions mirror the v4 chain: the tun2socks helper (fwmark), the app's own core (uid), + * loopback, link-local / multicast (NDP/RA/MLD) and ULA/LAN destinations. Per-app selection + * is honored: in bypass mode the bypassed apps keep native v6; in proxy mode only the + * selected apps lose v6 (everything else stays fully direct). + */ + private fun buildV6Blackhole( + appUid: Int, + perAppEnabled: Boolean, + bypassApps: Boolean, + selectedUids: List, + ): String { + val chain = AppConfig.ROOT_V6_CHAIN + val allowMode = perAppEnabled && !bypassApps + val bypassSelected = perAppEnabled && bypassApps && selectedUids.isNotEmpty() + val reject = "-j REJECT --reject-with icmp6-adm-prohibited" + return buildString { + appendLine("ip6tables -t filter -N $chain 2>/dev/null || true") + appendLine("ip6tables -t filter -F $chain") + // never touch the helper, the core, loopback, NDP/link-local/multicast or LAN + appendLine("ip6tables -t filter -A $chain -m mark --mark $FWMARK -j RETURN") + appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $appUid -j RETURN") + appendLine("ip6tables -t filter -A $chain -o lo -j RETURN") + bypassCidrsV6.forEach { appendLine("ip6tables -t filter -A $chain -d $it -j RETURN") } + // bypass mode: bypassed apps keep their native v6 + if (bypassSelected) { + selectedUids.forEach { appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $it -j RETURN") } + } + if (allowMode) { + // proxy mode: only the selected apps lose v6 (so they fall back to v4-via-proxy). + // None resolved -> reject nothing, mirroring the v4 chain's fail-closed handling. + selectedUids.forEach { appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $it $reject") } + } else { + // all-apps / bypass: reject everyone left + appendLine("ip6tables -t filter -A $chain $reject") + } + appendLine("ip6tables -t filter -D OUTPUT -j $chain 2>/dev/null || true") + appendLine("ip6tables -t filter -A OUTPUT -j $chain") + } + } + + // -------------------------------------------------- LAN / tethering sharing + + /** + * Route Wi-Fi-hotspot / USB-tethered clients through the tun as well (ipv4). + * Best-effort: wrapped in `set +e` so a failure here never breaks the working proxy. + * Mirrors Magic_V2Ray's hotspot rules (FORWARD accept, DNS DNAT, source-based policy + * routing for private client ranges, MSS clamp). + */ + private fun buildLanShareSetup(captureDeviceTraffic: Boolean, ipv6: Boolean): String { + val fwd = AppConfig.ROOT_FWD_CHAIN + val dnsChain = AppConfig.ROOT_DNS_CHAIN + val v6fwd = AppConfig.ROOT_V6_FWD_CHAIN + val v6pre = AppConfig.ROOT_V6_PRE_CHAIN + // Use the app's configured remote DNS (first plain IPv4) as the DNAT target for + // tethered clients; fall back to the default when it's a DoH/DoT/IPv6 value that + // can't be a DNAT target. + val dns = SettingsManager.getRemoteDnsServers() + .firstOrNull { Utils.isPureIpAddress(it) && !it.contains(":") } + ?: AppConfig.ROOT_LAN_DNS + val lanCidrs = listOf("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") + return buildString { + appendLine("set +e") + appendLine("echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true") + // forward traffic to/from the tun + appendLine("iptables -N $fwd 2>/dev/null || true") + appendLine("iptables -F $fwd") + appendLine("iptables -A $fwd -i $TUN -j ACCEPT") + appendLine("iptables -A $fwd -o $TUN -j ACCEPT") + appendLine("iptables -D FORWARD -j $fwd 2>/dev/null || true") + appendLine("iptables -I FORWARD -j $fwd") + // clamp MSS to avoid TLS fragmentation overhead through the tunnel + appendLine("iptables -t mangle -D FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350 2>/dev/null || true") + appendLine("iptables -t mangle -A FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350") + // hijack tethered clients' DNS into a dedicated chain so it resolves through the + // tunnel; the chain keeps teardown independent of the resolver IP + appendLine("iptables -t nat -N $dnsChain 2>/dev/null || true") + appendLine("iptables -t nat -F $dnsChain") + lanCidrs.forEach { + appendLine("iptables -t nat -A $dnsChain ! -i $TUN -d $it -p udp --dport 53 -j DNAT --to $dns") + } + appendLine("iptables -t nat -D PREROUTING -j $dnsChain 2>/dev/null || true") + appendLine("iptables -t nat -A PREROUTING -j $dnsChain") + // policy routing: return-path via main, LAN direct, the rest via the tun table + appendLine("ip rule add iif lo goto 6000 pref 5000 2>/dev/null || true") + appendLine("ip rule add iif $TUN lookup main suppress_prefixlength 0 pref 5010 2>/dev/null || true") + appendLine("ip rule add iif $TUN goto 6000 pref 5020 2>/dev/null || true") + appendLine("ip rule add to 10.0.0.0/8 lookup main pref 5025 2>/dev/null || true") + appendLine("ip rule add to 172.16.0.0/12 lookup main pref 5026 2>/dev/null || true") + appendLine("ip rule add to 192.168.0.0/16 lookup main pref 5027 2>/dev/null || true") + appendLine("ip rule add from 10.0.0.0/8 lookup $TABLE pref 5030 2>/dev/null || true") + appendLine("ip rule add from 172.16.0.0/12 lookup $TABLE pref 5040 2>/dev/null || true") + appendLine("ip rule add from 192.168.0.0/16 lookup $TABLE pref 5050 2>/dev/null || true") + appendLine("ip rule add nop pref 6000 2>/dev/null || true") + + // ---------------------------------------------------------- IPv6 clients + // Tethered/hotspot clients get a native (RA-assigned) global IPv6. The IPv4 rules + // above don't touch it, so it egresses the upstream interface directly, bypassing + // the proxy = IPv6 leak. Handle it explicitly (mirrors vincentng295/Magic_V2Ray + // cae4f7f): route it through the tun when v6 is enabled, reject it when it isn't. + appendLine("ip6tables -N $v6fwd 2>/dev/null || true") + appendLine("ip6tables -F $v6fwd") + appendLine("ip6tables -D FORWARD -j $v6fwd 2>/dev/null || true") + appendLine("ip6tables -I FORWARD -j $v6fwd") + if (ipv6) { + // When the device itself isn't capturing v6 (VPN-mode sharing) the tun table + // has no v6 default and the tun has no v6 address — add them so marked client + // v6 has somewhere to go. In Root mode the device-capture block already did. + if (!captureDeviceTraffic) { + appendLine("ip -6 addr add ${AppConfig.ROOT_TUN_ADDR_V6} dev $TUN 2>/dev/null || true") + appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true") + appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true") + } + // allow forwarding to/from the tun + appendLine("ip6tables -A $v6fwd -i $TUN -j ACCEPT") + appendLine("ip6tables -A $v6fwd -o $TUN -j ACCEPT") + // mark forwarded (non-locally-sourced) client v6 into the tun table. DNS first + // so a query to a LAN/router resolver is still tunneled (MARK survives RETURN); + // keep loopback, link-local (NDP/RA) and ULA/multicast direct. + appendLine("ip6tables -t mangle -N $v6pre 2>/dev/null || true") + appendLine("ip6tables -t mangle -F $v6pre") + appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -p udp --dport 53 -j MARK --set-xmark $MARK") + appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -p tcp --dport 53 -j MARK --set-xmark $MARK") + bypassCidrsV6.forEach { appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -d $it -j RETURN") } + appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -m addrtype ! --src-type LOCAL -j MARK --set-xmark $MARK") + appendLine("ip6tables -t mangle -D PREROUTING -j $v6pre 2>/dev/null || true") + appendLine("ip6tables -t mangle -A PREROUTING -j $v6pre") + // fail closed: any forwarded v6 that wasn't marked into the tun (e.g. the + // addrtype match is unavailable, or marking failed) is rejected rather than + // leaked straight out the upstream interface. + appendLine("ip6tables -A $v6fwd -j REJECT --reject-with icmp6-no-route") + } else { + // v6 disabled: reject forwarded clients' native v6 so it can't leak past the + // proxy (the device's own v6 is blackholed separately in OUTPUT). + appendLine("ip6tables -A $v6fwd -j REJECT --reject-with icmp6-no-route") + } + } + } + + // ---------------------------------------------------------------- teardown + + private fun buildTeardown(context: Context): String { + val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR) + val pidFile = File(runDir, "tun2socks.pid").absolutePath + val oomGuardPid = File(runDir, "oomguard.pid").absolutePath + val corePid = Process.myPid() + return buildString { + // mangle (TUN2SOCKS), both families + for (cmd in listOf("iptables", "ip6tables")) { + appendLine("$cmd -t mangle -D OUTPUT -j $CHAIN 2>/dev/null || true") + appendLine("$cmd -t mangle -F $CHAIN 2>/dev/null || true") + appendLine("$cmd -t mangle -X $CHAIN 2>/dev/null || true") + } + // IPv6 blackhole chain (only set up when v6 is disabled; harmless if absent) + appendLine("ip6tables -t filter -D OUTPUT -j ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -t filter -F ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -t filter -X ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true") + // routing rule + table + appendLine("ip rule del fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true") + appendLine("ip -6 rule del fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true") + appendLine("ip route flush table $TABLE 2>/dev/null || true") + appendLine("ip -6 route flush table $TABLE 2>/dev/null || true") + // LAN / tethering sharing (always cleaned, harmless if it was never set up) + appendLine("iptables -D FORWARD -j ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true") + appendLine("iptables -F ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true") + appendLine("iptables -X ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true") + appendLine("iptables -t mangle -D FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350 2>/dev/null || true") + appendLine("iptables -t nat -D PREROUTING -j ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true") + appendLine("iptables -t nat -F ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true") + appendLine("iptables -t nat -X ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true") + // IPv6 LAN-sharing chains (forward accept/reject + forwarded-client marking) + appendLine("ip6tables -D FORWARD -j ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -F ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -X ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -t mangle -D PREROUTING -j ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -t mangle -F ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true") + appendLine("ip6tables -t mangle -X ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true") + for (pref in listOf(5000, 5010, 5020, 5025, 5026, 5027, 5030, 5040, 5050, 6000)) { + appendLine("ip rule del pref $pref 2>/dev/null || true") + } + // tun device down + helper process + appendLine("ip link set dev $TUN down 2>/dev/null || true") + appendLine("[ -f '$pidFile' ] && kill \$(cat '$pidFile') 2>/dev/null || true") + appendLine("rm -f '$pidFile'") + // stop the OOM re-pin loop and restore the core process's LMK priority + appendLine("[ -f '$oomGuardPid' ] && kill \$(cat '$oomGuardPid') 2>/dev/null || true") + appendLine("rm -f '$oomGuardPid'") + appendLine("echo 0 > /proc/$corePid/oom_score_adj 2>/dev/null || true") + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt new file mode 100644 index 00000000..c07719e5 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt @@ -0,0 +1,54 @@ +package com.v2ray.ang.root + +import android.content.Context +import com.v2ray.ang.AppConfig +import com.v2ray.ang.util.LogUtil +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Minimal root command runner backed by the `su` binary. + * + * Scripts are written to the app's private root runtime dir and executed with + * `su -c sh ` so shell quoting stays simple. stderr is merged into stdout to + * avoid pipe-buffer deadlocks. + */ +object RootShell { + + data class Result(val code: Int, val output: String) { + val success: Boolean get() = code == 0 + } + + /** Write [script] to `/root/` and run it as root. */ + fun runScript(context: Context, name: String, script: String): Result { + val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() } + val file = File(dir, name).apply { + writeText(script) + setExecutable(true, false) + } + return exec("sh ${file.absolutePath}") + } + + fun exec(command: String, timeoutSeconds: Long = 30): Result { + return try { + val process = ProcessBuilder("su", "-c", command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) + if (!finished) { + process.destroy() + LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command") + return Result(-1, output) + } + val result = Result(process.exitValue(), output) + if (!result.success) { + LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}") + } + result + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e) + Result(-1, e.message ?: e.javaClass.simpleName) + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt new file mode 100644 index 00000000..a00d55ef --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt @@ -0,0 +1,96 @@ +package com.v2ray.ang.service + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import com.v2ray.ang.AppConfig +import com.v2ray.ang.contracts.ServiceControl +import com.v2ray.ang.core.CoreServiceManager +import com.v2ray.ang.root.RootProxyManager +import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.util.LogUtil +import com.v2ray.ang.util.MyContextWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.lang.ref.SoftReference + +/** + * Foreground service for the root (system-wide) run modes. Unlike [CoreVpnService] it + * does not use Android VpnService — traffic is routed by iptables instead + * (see [RootProxyManager]). + * + * The in-process core is started first (so its listener is up and the foreground + * notification is posted promptly), then the root routing rules are installed off the + * main thread. On teardown the rules are removed before the core stops. + */ +class CoreRootService : Service(), ServiceControl { + + private var setupJob: Job? = null + + override fun onCreate() { + super.onCreate() + LogUtil.i(AppConfig.TAG, "StartCore-Root: Service created") + CoreServiceManager.serviceControl = SoftReference(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + LogUtil.i(AppConfig.TAG, "StartCore-Root: command received") + + // Start the in-process core first (this also posts the foreground notification), + // then install the root routing off the main thread. + if (!CoreServiceManager.startCoreLoop(null)) { + LogUtil.e(AppConfig.TAG, "StartCore-Root: core failed to start") + stopService() + return START_NOT_STICKY + } + + setupJob = CoroutineScope(Dispatchers.IO).launch { + if (!RootProxyManager.start(this@CoreRootService)) { + LogUtil.e(AppConfig.TAG, "StartCore-Root: failed to start root mode, stopping") + stopService() + } + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + // Wait for any in-flight async setup to finish before tearing down. The rules are + // installed off the main thread and can take seconds (the setup script waits for the + // tun to appear); if a stop arrives during that window, teardown would run first and + // the setup would then re-install the rules + tun pointing at a now-dead core, + // blackholing all traffic until the next start/stop cycle clears it. + runBlocking { setupJob?.cancelAndJoin() } + // Remove routing rules BEFORE stopping the core so traffic is never redirected + // to a dead listener. Synchronous on purpose — leaving rules behind breaks the net. + RootProxyManager.stop(this) + CoreServiceManager.stopCoreLoop() + } + + override fun getService(): Service = this + + override fun startService() { + // do nothing + } + + override fun stopService() { + stopSelf() + } + + override fun vpnProtect(socket: Int): Boolean = true + + override fun onBind(intent: Intent?): IBinder? = null + + override fun attachBaseContext(newBase: Context?) { + val context = newBase?.let { + MyContextWrapper.wrap(newBase, SettingsManager.getLocale()) + } + super.attachBaseContext(context) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt index 7d14ac22..d00cbdc6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt @@ -24,6 +24,7 @@ import com.v2ray.ang.core.CoreServiceManager import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.root.RootLanSharing import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.Utils @@ -134,6 +135,9 @@ class CoreVpnService : VpnService(), ServiceControl { stopAllService() return } + + // Start LAN sharing if enabled in settings + RootLanSharing.startClientSharing(this) } override fun stopService() { @@ -362,6 +366,8 @@ class CoreVpnService : VpnService(), ServiceControl { tun2SocksService?.stopTun2Socks() tun2SocksService = null + RootLanSharing.stopClientSharing(this) + CoreServiceManager.stopCoreLoop() if (isForced) { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt index 40dba1f7..bc13fce8 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt @@ -2,6 +2,7 @@ package com.v2ray.ang.ui import android.os.Bundle import android.view.View +import androidx.lifecycle.lifecycleScope import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference @@ -9,9 +10,12 @@ import androidx.preference.PreferenceFragmentCompat import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.VPN import com.v2ray.ang.R +import com.v2ray.ang.extension.toastError import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.helper.MmkvPreferenceDataStore +import com.v2ray.ang.root.RootManager import com.v2ray.ang.util.Utils +import kotlinx.coroutines.launch class SettingsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -43,6 +47,8 @@ class SettingsActivity : BaseActivity() { private val fragmentMaxSplit by lazy { findPreference(AppConfig.PREF_FRAGMENT_MAXSPLIT) } private val mode by lazy { findPreference(AppConfig.PREF_MODE) } + private val enableRootMode by lazy { findPreference(AppConfig.PREF_ROOT_MODE_ENABLE) } + private val lanSharing by lazy { findPreference(AppConfig.PREF_ROOT_LAN_SHARING) } private val hevTunLogLevel by lazy { findPreference(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) } private val hevTunRwTimeout by lazy { findPreference(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) } @@ -97,6 +103,7 @@ class SettingsActivity : BaseActivity() { updateMode(valueStr) true } + mode?.dialogLayoutResource = R.layout.preference_with_help_link useHevTun?.setOnPreferenceChangeListener { _, newValue -> @@ -113,6 +120,33 @@ class SettingsActivity : BaseActivity() { updateDynamicSocksPort(newValue as Boolean) true } + + enableRootMode?.setOnPreferenceChangeListener { _, newValue -> + if (newValue == true && !RootManager.cachedRoot()) { + lifecycleScope.launch { + if (checkAndRequestRoot()) { + enableRootMode?.isChecked = true + } + } + false + } else { + true + } + } + + lanSharing?.setOnPreferenceChangeListener { _, newValue -> + if (newValue == true && !RootManager.cachedRoot()) { + lifecycleScope.launch { + if (checkAndRequestRoot()) { + lanSharing?.isChecked = true + } + } + false + } else { + true + } + } + } private fun initPreferenceSummaries() { @@ -161,6 +195,15 @@ class SettingsActivity : BaseActivity() { preferenceScreen?.let { traverse(it) } } + private suspend fun checkAndRequestRoot(): Boolean { + val hasRoot = RootManager.refresh() + if (!isAdded) return false + if (!hasRoot) { + context?.toastError(R.string.toast_root_required) + } + return hasRoot + } + override fun onStart() { super.onStart() updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)) diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml index a6c9c27a..e49cd3fb 100644 --- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml @@ -284,6 +284,13 @@ 选择启用后 TUN 将使用 hev-socks5-tunnel 否则使用 xray-core HevTun 日志级别 HevTun 读写超时(秒) (tcp,udp 默认 300,60) + 模式设置 + 启用 Root 模式 (仅限 Root 用户) + 通过 Root 权限启用底层透明代理。此模式将不再通过 Android 常规系统 VPN 建立连接,而是直接接管系统底层所有网络流量。开启后,原有的常规模式、应用分流及相关 VPN 设置将直接失效。 + 此功能需要 Root 权限 + 局域网 / 热点网络共享 (仅限 Root 用户) + 允许通过 Wi-Fi 热点和 USB 共享网络连接的设备走 VPN 代理流量 + Logcat 复制 diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml index 4c70cef5..7768fd16 100644 --- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml @@ -283,6 +283,13 @@ 選擇啟用後 TUN 將使用 hev-socks5-tunnel 否則使用 xray-core HevTun 日誌級別 HevTun 讀寫逾時(秒) (tcp,udp 預設 300,60) + 模式設定 + 啟用 Root 模式 (僅限 Root 使用者) + 透過 Root 權限啟用底層透明代理。此模式將不再透過 Android 常規系統 VPN 建立連線,而是直接接管系統底層所有網路流量。開啟後,原有的常规模式、應用分流及相關 VPN 設定將直接失效。 + 此功能需要 Root 權限 + 區域網路 / 熱點網路共享 (僅限 Root 使用者) + 允許透過 Wi-Fi 熱點和 USB 共享網路連線的裝置走 VPN 代理流量 + Logcat 複製 diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index 4624ffb1..419e5b6a 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -291,6 +291,12 @@ When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core. Hev Tun Log Level Hev Tun read/write timeout (seconds) (tcp,udp default 300,60) + Mode Settings + Root mode enabled (Root Users) + Enable low-level transparent proxying via root privileges. This mode bypasses the standard Android system VPN to directly take over all underlying network traffic. Once enabled, the original regular mode, per-app routing, and related VPN settings will become completely invalid. + Root access is required for this feature + LAN / tethering sharing (Root Users) + Route Wi-Fi hotspot and USB-tethered devices through the VPN Logcat Copy diff --git a/V2rayNG/app/src/main/res/xml/pref_settings.xml b/V2rayNG/app/src/main/res/xml/pref_settings.xml index 729c5755..78114e3c 100644 --- a/V2rayNG/app/src/main/res/xml/pref_settings.xml +++ b/V2rayNG/app/src/main/res/xml/pref_settings.xml @@ -314,6 +314,10 @@ android:summary="@string/summary_pref_ip_api_url" android:title="@string/title_pref_ip_api_url" /> + + + + + + + + + \ No newline at end of file diff --git a/compile-hevtun.sh b/compile-hevtun.sh index 6aa01c65..a6793ea6 100644 --- a/compile-hevtun.sh +++ b/compile-hevtun.sh @@ -16,26 +16,88 @@ clear_tmp () { } trap 'echo -e "Aborted, error $? in command: $BASH_COMMAND"; trap ERR; clear_tmp; exit 1' ERR INT -#build hev-socks5-tunnel +ABIS="armeabi-v7a arm64-v8a x86 x86_64" + mkdir -p "$TMPDIR/jni" pushd "$TMPDIR" -echo 'include $(call all-subdir-makefiles)' > jni/Android.mk - ln -s "$__dir/hev-socks5-tunnel" jni/hev-socks5-tunnel +# 1) JNI shared library (libhev-socks5-tunnel.so) — loaded in-process by +# com.v2ray.ang.service.TProxyService for the VpnService hev tun mode. +echo 'include $(call all-subdir-makefiles)' > jni/Android.mk + "$NDK_HOME/ndk-build" \ NDK_PROJECT_PATH=. \ APP_BUILD_SCRIPT=jni/Android.mk \ - "APP_ABI=armeabi-v7a arm64-v8a x86 x86_64" \ + "APP_ABI=$ABIS" \ APP_PLATFORM=android-24 \ NDK_LIBS_OUT="$TMPDIR/libs" \ NDK_OUT="$TMPDIR/obj" \ "APP_CFLAGS=-O3 -DPKGNAME=com/v2ray/ang/service" \ "APP_LDFLAGS=-Wl,--build-id=none -Wl,--hash-style=gnu" \ +# 2) Standalone executable (libhevsockstun.so) — run as a separate root +# process by com.v2ray.ang.core.root for the Root run mode. Same hev source, +# no -DENABLE_LIBRARY so hev-main.c's main() is built, and BUILD_EXECUTABLE +# instead of a shared library. It creates its own tun and reads a YAML config. +cat > jni/exec.mk <<'EXECMK' +TOP_PATH := $(call my-dir)/hev-socks5-tunnel + +ifeq ($(filter $(modules-get-list),yaml),) + include $(TOP_PATH)/third-part/yaml/Android.mk +endif +ifeq ($(filter $(modules-get-list),lwip),) + include $(TOP_PATH)/third-part/lwip/Android.mk +endif +ifeq ($(filter $(modules-get-list),hev-task-system),) + include $(TOP_PATH)/third-part/hev-task-system/Android.mk +endif + +LOCAL_PATH := $(TOP_PATH) +SRCDIR := $(LOCAL_PATH)/src + +include $(CLEAR_VARS) +include $(LOCAL_PATH)/build.mk +LOCAL_MODULE := hevsockstun +LOCAL_SRC_FILES := $(patsubst $(SRCDIR)/%,src/%,$(SRCFILES)) +LOCAL_C_INCLUDES := \ + $(LOCAL_PATH)/src \ + $(LOCAL_PATH)/src/misc \ + $(LOCAL_PATH)/src/core/include \ + $(LOCAL_PATH)/third-part/yaml/include \ + $(LOCAL_PATH)/third-part/lwip/src/include \ + $(LOCAL_PATH)/third-part/lwip/src/ports/include \ + $(LOCAL_PATH)/third-part/hev-task-system/include +LOCAL_CFLAGS += -DFD_SET_DEFINED -DSOCKLEN_T_DEFINED +LOCAL_CFLAGS += $(VERSION_CFLAGS) +ifeq ($(TARGET_ARCH_ABI),armeabi-v7a) +LOCAL_CFLAGS += -mfpu=neon +endif +LOCAL_STATIC_LIBRARIES := yaml lwip hev-task-system +LOCAL_LDFLAGS += -Wl,-z,max-page-size=16384 +LOCAL_LDFLAGS += -Wl,-z,common-page-size=16384 +include $(BUILD_EXECUTABLE) +EXECMK + +"$NDK_HOME/ndk-build" \ + NDK_PROJECT_PATH=. \ + APP_BUILD_SCRIPT=jni/exec.mk \ + "APP_ABI=$ABIS" \ + APP_PLATFORM=android-24 \ + NDK_LIBS_OUT="$TMPDIR/libs-exec" \ + NDK_OUT="$TMPDIR/obj-exec" \ + "APP_CFLAGS=-O3" \ + "APP_LDFLAGS=-Wl,--build-id=none -Wl,--hash-style=gnu" \ + +# Stage both artifacts under libs//. The executable is renamed to +# lib*.so so the APK installer extracts it into nativeLibraryDir as an +# executable file (filename distinct from the JNI library above). mkdir -p "$__dir/libs" cp -r "$TMPDIR/libs/"* "$__dir/libs/" +for abi in $ABIS; do + cp "$TMPDIR/libs-exec/$abi/hevsockstun" "$__dir/libs/$abi/libhevsockstun.so" +done popd rm -rf $TMPDIR