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:
1 parent
42c12fdef4
commit
e83dba94a0
17 files changed
+970
-8
No files matched your search
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user