feat: add a root, system-wide run mode without VpnService (#5812)

* 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>
This commit is contained in:
Alizaand2dust authored and GitHub committed 2026-06-28 11:03:36 +08:00
1 parent 42c12fdef4
commit e83dba94a0
17 files changed
+970 -8

No files matched your search

+11
View File
@@ -201,6 +201,17 @@
android:value="proxy" /> android:value="proxy" />
</service> </service>
<service
android:name=".service.CoreRootService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service <service
android:name=".service.CoreTestService" android:name=".service.CoreTestService"
android:exported="false" android:exported="false"
@@ -70,6 +70,8 @@ object AppConfig {
const val PREF_LOGLEVEL = "pref_core_loglevel" const val PREF_LOGLEVEL = "pref_core_loglevel"
const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method" const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
const val PREF_MODE = "pref_mode" const val PREF_MODE = "pref_mode"
const val PREF_ROOT_MODE_ENABLE = "pref_root_mode_enabled"
const val PREF_ROOT_LAN_SHARING = "pref_root_lan_sharing"
const val PREF_IS_BOOTED = "pref_is_booted" const val PREF_IS_BOOTED = "pref_is_booted"
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release" const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources" const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
@@ -196,6 +198,27 @@ object AppConfig {
const val VPN = "VPN" const val VPN = "VPN"
const val VPN_MTU = 1500 const val VPN_MTU = 1500
/** Root (system-wide) mode runtime constants. */
const val ROOT_RUNTIME_DIR = "root"
const val ROOT_IPTABLES_CHAIN = "V2RAY_NG"
const val ROOT_FWMARK = 255 // defensive RETURN tag; hev's only upstream socket is loopback (already bypassed)
const val ROOT_MARK_ROUTE = 1 // packets we want pushed into the tun device
const val ROOT_ROUTE_TABLE = 2024
const val ROOT_RULE_PRIORITY = 1000
const val ROOT_TUN_NAME = "v2raytun0"
const val ROOT_TUN_ADDR_V4 = "198.18.0.1/15"
const val ROOT_TUN_ADDR_V6 = "fdfe:dcba:9876::1/64"
// hev-socks5-tunnel run as a standalone root binary (reuses the same project already
// bundled for the VPN hev path; distinct filename from the JNI lib to avoid collision).
const val ROOT_TUN2SOCKS_BIN = "libhevsockstun.so"
const val ROOT_FWD_CHAIN = "V2RAY_NG_FWD" // FORWARD chain for LAN/tethering sharing
const val ROOT_DNS_CHAIN = "V2RAY_NG_DNS" // nat chain for tethered-client DNS DNAT
const val ROOT_V6_CHAIN = "V2RAY_NG6" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
const val ROOT_V6_FWD_CHAIN = "V2RAY_NG6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
const val ROOT_V6_PRE_CHAIN = "V2RAY_NG6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
const val ROOT_LAN_DNS = "1.1.1.1" // fallback resolver for tethered clients when no plain-IPv4 DNS is configured
const val ROOT_OOM_SCORE = "-1000" // oom_score_adj that makes the LMK never kill us
/** hev-sock5-tunnel read-write-timeout value */ /** hev-sock5-tunnel read-write-timeout value */
const val HEVTUN_RW_TIMEOUT = "300,60" const val HEVTUN_RW_TIMEOUT = "300,60"
@@ -168,6 +168,7 @@ object CoreConfigManager {
configureFakeDns(v2rayConfig) configureFakeDns(v2rayConfig)
configureDns(v2rayConfig, policyGroupBalancerTags) configureDns(v2rayConfig, policyGroupBalancerTags)
configureLocalDns(v2rayConfig) configureLocalDns(v2rayConfig)
configureRootModeDns(v2rayConfig)
// (added by getDns / getCustomLocalDns) to use the balancer, then add // (added by getDns / getCustomLocalDns) to use the balancer, then add
// the catch-all balancer rule. // the catch-all balancer rule.
@@ -452,8 +453,10 @@ object CoreConfigManager {
val vpn = SettingsManager.isVpnMode() val vpn = SettingsManager.isVpnMode()
val useHev = SettingsManager.isUsingHevTun() val useHev = SettingsManager.isUsingHevTun()
val forcedByHev = vpn && useHev val forcedByHev = vpn && useHev
val forcedBySocksRoot = SettingsManager.isRootMode()
|| MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true) val enableLocalProxy = forcedByHev || forcedBySocksRoot || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
val socksPort = SettingsManager.getSocksPort() val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername() val socksUsername = SettingsManager.getSocksUsername()
@@ -622,6 +625,39 @@ object CoreConfigManager {
} }
} }
/**
* In the root mode the whole device's traffic (incl. raw DNS) is funneled
* into the core's SOCKS inbound, exactly like the VPN+hev path. Hijack port-53 to the
* core's DNS module so queries are resolved via the configured resolver through the
* proxy instead of leaking to (or being mis-resolved by) the local network resolver.
* Independent of the local-DNS toggle, which is not exposed for root mode.
*/
private fun configureRootModeDns(v2rayConfig: V2rayConfig) {
if (!SettingsManager.isRootMode()) return
if (v2rayConfig.routing.rules.none { it.outboundTag == "dns-out" && it.port == "53" }) {
v2rayConfig.routing.rules.add(
0,
V2rayConfig.RoutingBean.RulesBean(
inboundTag = arrayListOf("socks"),
outboundTag = "dns-out",
port = "53",
)
)
}
if (v2rayConfig.outbounds.none { it.protocol == "dns" && it.tag == "dns-out" }) {
v2rayConfig.outbounds.add(
V2rayConfig.OutboundBean(
protocol = "dns",
tag = "dns-out",
settings = null,
streamSettings = null,
mux = null
)
)
}
}
/** /**
* Remove speed-test runtime sections when the feature is disabled. * Remove speed-test runtime sections when the feature is disabled.
*/ */
@@ -15,15 +15,16 @@ import com.v2ray.ang.R
import com.v2ray.ang.contracts.ServiceControl import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.OutboundTrafficStat import com.v2ray.ang.dto.OutboundTrafficStat
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SpeedtestManager import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.service.CoreProxyOnlyService import com.v2ray.ang.service.CoreProxyOnlyService
import com.v2ray.ang.service.CoreRootService
import com.v2ray.ang.service.CoreVpnService import com.v2ray.ang.service.CoreVpnService
import com.v2ray.ang.service.DialerNativeService import com.v2ray.ang.service.DialerNativeService
import com.v2ray.ang.service.DialerWebviewService import com.v2ray.ang.service.DialerWebviewService
@@ -172,8 +173,16 @@ object CoreServiceManager {
context.toast(R.string.toast_services_start) context.toast(R.string.toast_services_start)
} }
val isVpnMode = SettingsManager.isVpnMode() val isRootMode = SettingsManager.isRootMode()
val intent = if (isVpnMode) { if (isRootMode && !RootManager.isRootAvailable()) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: root mode requires root but none available")
error(context.getString(R.string.toast_root_required))
}
val intent = if (isRootMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Root service")
Intent(context.applicationContext, CoreRootService::class.java)
} else if (SettingsManager.isVpnMode()) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service") LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, CoreVpnService::class.java) Intent(context.applicationContext, CoreVpnService::class.java)
} else { } else {
@@ -487,6 +487,13 @@ object SettingsManager {
return mode == null || mode == VPN return mode == null || mode == VPN
} }
/**
* Check if a root (system-wide) run mode is selected.
*/
fun isRootMode(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
}
/** /**
* Check if process routing can be used. * Check if process routing can be used.
*/ */
@@ -0,0 +1,50 @@
package com.v2ray.ang.root
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object RootLanSharing {
private var lanSharingStarted = false
private var lanShareJob: Job? = null
/**
* Optional root feature: share the proxy with tethered LAN/USB clients while the
* device itself stays on the VpnService. Runs a dedicated client hev-socks5-tunnel
* off the main thread so the su calls don't block service startup.
* The cheap, usually-false preference is checked first so the common path
* short-circuits before touching root state.
*/
fun startClientSharing(context: Context): Boolean {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING) && RootManager.cachedRoot()) {
if (lanShareJob != null) return false
lanSharingStarted = true
lanShareJob = CoroutineScope(Dispatchers.IO).launch { RootProxyManager.startClientSharing(context) }
}
return true
}
/**
* Remove LAN/tethering sharing rules + helper before stopping the core. Wait for the
* async setup to finish first, otherwise a stop during setup tears down before the
* rules are installed and they leak (orphan FORWARD/policy-routing rules + client tun).
*/
fun stopClientSharing(context: Context) {
if (!lanSharingStarted) return
lanSharingStarted = false
runBlocking { lanShareJob?.cancelAndJoin() }
lanShareJob = null
RootProxyManager.stop(context)
}
}
@@ -0,0 +1,62 @@
package com.v2ray.ang.root
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
/**
* Detects whether the device grants root (`su`) access.
*
* The result is cached after the first successful probe. Probing spawns `su` and
* blocks, so [refresh] (a suspending call on [Dispatchers.IO]) should be used from UI
* code; [isRootAvailable] may block and must not be called on the main thread the first time.
*/
object RootManager {
@Volatile
private var cached: Boolean? = null
/** Last known result without probing. Defaults to false when never probed. */
fun cachedRoot(): Boolean = cached ?: false
/**
* Returns whether root is available, probing once if unknown.
* May block while `su` is spawned; avoid calling on the main thread before a probe.
*/
fun isRootAvailable(forceRefresh: Boolean = false): Boolean {
if (!forceRefresh) cached?.let { return it }
val result = probe()
cached = result
return result
}
/** Probes root off the main thread, updates the cache, and returns the result. */
suspend fun refresh(): Boolean = withContext(Dispatchers.IO) {
val result = probe()
cached = result
result
}
private fun probe(): Boolean {
return try {
val process = ProcessBuilder("su", "-c", "id -u")
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
val finished = process.waitFor(10, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out")
return false
}
val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0"
LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot")
isRoot
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})")
false
}
}
}
@@ -0,0 +1,466 @@
package com.v2ray.ang.root
import android.content.Context
import android.os.Process
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import com.v2ray.ang.util.Utils
import java.io.File
/**
* Installs and removes the iptables / ip-rule routing that pushes system-wide traffic
*
* A bundled `hev-socks5-tunnel` binary (run as root) creates a tun device and forwards it to
* the in-process core's SOCKS inbound; a mangle MARK chain plus a dedicated routing table /
* ip rule steer all traffic into the tun. Full TCP + UDP.
*
* All rules live in dedicated chains ([AppConfig.ROOT_IPTABLES_CHAIN] in the mangle
* table, [AppConfig.ROOT_FWD_CHAIN] for LAN sharing) plus a dedicated routing table, so
* [teardown] is a clean, bounded flush. Teardown runs before every setup (to clear stale
* rules) and on every stop path — leaving rules behind after the core dies would break
* the device's connectivity.
*/
object RootProxyManager {
private const val CHAIN = AppConfig.ROOT_IPTABLES_CHAIN
private const val TUN = AppConfig.ROOT_TUN_NAME
private const val TABLE = AppConfig.ROOT_ROUTE_TABLE
private const val PRIORITY = AppConfig.ROOT_RULE_PRIORITY
private const val FWMARK = AppConfig.ROOT_FWMARK
private const val MARK = AppConfig.ROOT_MARK_ROUTE
// Local / private / multicast destinations that must never be proxied.
private val bypassCidrs = listOf(
"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4"
)
// IPv6 equivalents (loopback, link-local, ULA/private, multicast). Feeding the v4 list
// above to ip6tables silently fails, so the v6 chain needs its own.
private val bypassCidrsV6 = listOf(
"::1/128", "fe80::/10", "fc00::/7", "ff00::/8"
)
fun start(context: Context): Boolean {
teardown(context)
val script = buildTun2socksSetup(context) ?: return false
val result = RootShell.runScript(context, "setup_rules.sh", script)
if (!result.success) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: setup failed, rolling back:\n${result.output}")
teardown(context)
return false
}
return true
}
/**
* Set up LAN/tethering sharing while the device itself uses another mode (e.g. VPN
* mode). Runs a dedicated client tun2socks into the in-process core's SOCKS inbound
* and forwards tethered clients into it, WITHOUT capturing the device's own traffic
* (that keeps flowing through the VpnService). Requires root.
*/
fun startClientSharing(context: Context): Boolean {
teardown(context)
val script = buildTun2socksSetup(context, captureDeviceTraffic = false, forceLanShare = true)
?: return false
val result = RootShell.runScript(context, "setup_rules.sh", script)
if (!result.success) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: client sharing setup failed:\n${result.output}")
teardown(context)
return false
}
LogUtil.i(AppConfig.TAG, "RootProxyManager: LAN client sharing installed")
return true
}
/** Remove all rules and stop helper processes. Safe to call repeatedly. */
fun stop(context: Context) {
teardown(context)
LogUtil.i(AppConfig.TAG, "RootProxyManager: rules removed")
}
private fun teardown(context: Context) {
RootShell.runScript(context, "teardown_rules.sh", buildTeardown(context))
}
// --------------------------------------------------------------- TUN2SOCKS
/**
* @param captureDeviceTraffic when true (Root mode) the device's own OUTPUT traffic is
* marked into the tun. When false (VPN-mode LAN sharing) the device keeps using the
* VpnService and only forwarded clients are routed into this tun.
* @param forceLanShare force the LAN/tethering forward rules on regardless of the pref
* (used by VPN-mode sharing, where the whole point is forwarding clients).
*/
private fun buildTun2socksSetup(
context: Context,
captureDeviceTraffic: Boolean = true,
forceLanShare: Boolean = false,
): String? {
val bin = File(context.applicationInfo.nativeLibraryDir, AppConfig.ROOT_TUN2SOCKS_BIN)
if (!bin.exists()) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: hev-socks5-tunnel binary missing at ${bin.absolutePath}")
return null
}
val appUid = context.applicationInfo.uid
val port = SettingsManager.getSocksPort()
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val pidFile = File(runDir, "tun2socks.pid").absolutePath
val logFile = File(runDir, "tun2socks.log").absolutePath
val cfgFile = File(runDir, "tun2socks.yml").absolutePath
val oomGuardPid = File(runDir, "oomguard.pid").absolutePath
val ipv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)
val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
val corePid = Process.myPid()
// Per-app proxy/bypass (mirrors what VpnService does via allowed/disallowed apps).
val perAppEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
val selectedUids = if (perAppEnabled) {
val pkgs = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toList().orEmpty()
if (pkgs.isNotEmpty()) PackageUidResolver.packageNamesToUids(context, pkgs) else emptyList()
} else {
emptyList()
}
return buildString {
appendLine("set -e")
appendLine("BIN='${bin.absolutePath}'")
// Protect the core (this app process) from the Android low-memory killer.
// system_server keeps recomputing oom_score_adj for app processes, so a single
// write would be reverted — re-pin it from a small root loop instead.
appendLine("nohup sh -c 'while true; do echo ${AppConfig.ROOT_OOM_SCORE} > /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>,
): 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>,
): 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")
}
}
}
@@ -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 <file>` 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 `<filesDir>/root/<name>` 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)
}
}
}
@@ -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)
}
}
@@ -24,6 +24,7 @@ import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootLanSharing
import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -134,6 +135,9 @@ class CoreVpnService : VpnService(), ServiceControl {
stopAllService() stopAllService()
return return
} }
// Start LAN sharing if enabled in settings
RootLanSharing.startClientSharing(this)
} }
override fun stopService() { override fun stopService() {
@@ -362,6 +366,8 @@ class CoreVpnService : VpnService(), ServiceControl {
tun2SocksService?.stopTun2Socks() tun2SocksService?.stopTun2Socks()
tun2SocksService = null tun2SocksService = null
RootLanSharing.stopClientSharing(this)
CoreServiceManager.stopCoreLoop() CoreServiceManager.stopCoreLoop()
if (isForced) { if (isForced) {
@@ -2,6 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
@@ -9,9 +10,12 @@ import androidx.preference.PreferenceFragmentCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MmkvPreferenceDataStore import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -43,6 +47,8 @@ class SettingsActivity : BaseActivity() {
private val fragmentMaxSplit by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_MAXSPLIT) } private val fragmentMaxSplit by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_MAXSPLIT) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) } private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val enableRootMode by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_MODE_ENABLE) }
private val lanSharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_LAN_SHARING) }
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) } private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) } private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
@@ -97,6 +103,7 @@ class SettingsActivity : BaseActivity() {
updateMode(valueStr) updateMode(valueStr)
true true
} }
mode?.dialogLayoutResource = R.layout.preference_with_help_link mode?.dialogLayoutResource = R.layout.preference_with_help_link
useHevTun?.setOnPreferenceChangeListener { _, newValue -> useHevTun?.setOnPreferenceChangeListener { _, newValue ->
@@ -113,6 +120,33 @@ class SettingsActivity : BaseActivity() {
updateDynamicSocksPort(newValue as Boolean) updateDynamicSocksPort(newValue as Boolean)
true 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() { private fun initPreferenceSummaries() {
@@ -161,6 +195,15 @@ class SettingsActivity : BaseActivity() {
preferenceScreen?.let { traverse(it) } 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() { override fun onStart() {
super.onStart() super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)) updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
@@ -284,6 +284,13 @@
<string name="summary_pref_use_hev_tunnel">选择启用后 TUN 将使用 hev-socks5-tunnel 否则使用 xray-core</string> <string name="summary_pref_use_hev_tunnel">选择启用后 TUN 将使用 hev-socks5-tunnel 否则使用 xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">HevTun 日志级别</string> <string name="title_pref_hev_tunnel_loglevel">HevTun 日志级别</string>
<string name="title_pref_hev_tunnel_rw_timeout">HevTun 读写超时(秒) (tcp,udp 默认 300,60)</string> <string name="title_pref_hev_tunnel_rw_timeout">HevTun 读写超时(秒) (tcp,udp 默认 300,60)</string>
<string name="title_mode_settings">模式设置</string>
<string name="title_root_mode_enabled">启用 Root 模式 (仅限 Root 用户)</string>
<string name="summary_root_mode_enabled">通过 Root 权限启用底层透明代理。此模式将不再通过 Android 常规系统 VPN 建立连接,而是直接接管系统底层所有网络流量。开启后,原有的常规模式、应用分流及相关 VPN 设置将直接失效。</string>
<string name="toast_root_required">此功能需要 Root 权限</string>
<string name="title_root_lan_sharing">局域网 / 热点网络共享 (仅限 Root 用户)</string>
<string name="summary_root_lan_sharing">允许通过 Wi-Fi 热点和 USB 共享网络连接的设备走 VPN 代理流量</string>
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">复制</string> <string name="logcat_copy">复制</string>
@@ -283,6 +283,13 @@
<string name="summary_pref_use_hev_tunnel">選擇啟用後 TUN 將使用 hev-socks5-tunnel 否則使用 xray-core</string> <string name="summary_pref_use_hev_tunnel">選擇啟用後 TUN 將使用 hev-socks5-tunnel 否則使用 xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">HevTun 日誌級別</string> <string name="title_pref_hev_tunnel_loglevel">HevTun 日誌級別</string>
<string name="title_pref_hev_tunnel_rw_timeout">HevTun 讀寫逾時(秒) (tcp,udp 預設 300,60)</string> <string name="title_pref_hev_tunnel_rw_timeout">HevTun 讀寫逾時(秒) (tcp,udp 預設 300,60)</string>
<string name="title_mode_settings">模式設定</string>
<string name="title_root_mode_enabled">啟用 Root 模式 (僅限 Root 使用者)</string>
<string name="summary_root_mode_enabled">透過 Root 權限啟用底層透明代理。此模式將不再透過 Android 常規系統 VPN 建立連線,而是直接接管系統底層所有網路流量。開啟後,原有的常规模式、應用分流及相關 VPN 設定將直接失效。</string>
<string name="toast_root_required">此功能需要 Root 權限</string>
<string name="title_root_lan_sharing">區域網路 / 熱點網路共享 (僅限 Root 使用者)</string>
<string name="summary_root_lan_sharing">允許透過 Wi-Fi 熱點和 USB 共享網路連線的裝置走 VPN 代理流量</string>
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">複製</string> <string name="logcat_copy">複製</string>
@@ -291,6 +291,12 @@
<string name="summary_pref_use_hev_tunnel">When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core.</string> <string name="summary_pref_use_hev_tunnel">When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core.</string>
<string name="title_pref_hev_tunnel_loglevel">Hev Tun Log Level</string> <string name="title_pref_hev_tunnel_loglevel">Hev Tun Log Level</string>
<string name="title_pref_hev_tunnel_rw_timeout">Hev Tun read/write timeout (seconds) (tcp,udp default 300,60)</string> <string name="title_pref_hev_tunnel_rw_timeout">Hev Tun read/write timeout (seconds) (tcp,udp default 300,60)</string>
<string name="title_mode_settings">Mode Settings</string>
<string name="title_root_mode_enabled">Root mode enabled (Root Users)</string>
<string name="summary_root_mode_enabled">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.</string>
<string name="toast_root_required">Root access is required for this feature</string>
<string name="title_root_lan_sharing">LAN / tethering sharing (Root Users)</string>
<string name="summary_root_lan_sharing">Route Wi-Fi hotspot and USB-tethered devices through the VPN</string>
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">Copy</string> <string name="logcat_copy">Copy</string>
@@ -314,6 +314,10 @@
android:summary="@string/summary_pref_ip_api_url" android:summary="@string/summary_pref_ip_api_url"
android:title="@string/title_pref_ip_api_url" /> android:title="@string/title_pref_ip_api_url" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/title_mode_settings">
<ListPreference <ListPreference
android:defaultValue="VPN" android:defaultValue="VPN"
android:entries="@array/mode_entries" android:entries="@array/mode_entries"
@@ -322,6 +326,19 @@
android:summary="%s" android:summary="%s"
android:title="@string/title_mode" /> android:title="@string/title_mode" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_root_mode_enabled"
android:summary="@string/summary_root_mode_enabled"
android:title="@string/title_root_mode_enabled" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_root_lan_sharing"
android:summary="@string/summary_root_lan_sharing"
android:title="@string/title_root_lan_sharing" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>
+66 -4
View File
@@ -16,26 +16,88 @@ clear_tmp () {
} }
trap 'echo -e "Aborted, error $? in command: $BASH_COMMAND"; trap ERR; clear_tmp; exit 1' ERR INT 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" mkdir -p "$TMPDIR/jni"
pushd "$TMPDIR" pushd "$TMPDIR"
echo 'include $(call all-subdir-makefiles)' > jni/Android.mk
ln -s "$__dir/hev-socks5-tunnel" jni/hev-socks5-tunnel 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_HOME/ndk-build" \
NDK_PROJECT_PATH=. \ NDK_PROJECT_PATH=. \
APP_BUILD_SCRIPT=jni/Android.mk \ APP_BUILD_SCRIPT=jni/Android.mk \
"APP_ABI=armeabi-v7a arm64-v8a x86 x86_64" \ "APP_ABI=$ABIS" \
APP_PLATFORM=android-24 \ APP_PLATFORM=android-24 \
NDK_LIBS_OUT="$TMPDIR/libs" \ NDK_LIBS_OUT="$TMPDIR/libs" \
NDK_OUT="$TMPDIR/obj" \ NDK_OUT="$TMPDIR/obj" \
"APP_CFLAGS=-O3 -DPKGNAME=com/v2ray/ang/service" \ "APP_CFLAGS=-O3 -DPKGNAME=com/v2ray/ang/service" \
"APP_LDFLAGS=-Wl,--build-id=none -Wl,--hash-style=gnu" \ "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/<abi>/. 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" mkdir -p "$__dir/libs"
cp -r "$TMPDIR/libs/"* "$__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 popd
rm -rf $TMPDIR rm -rf $TMPDIR