Fix TUN DNS hijack and fd lifecycle for mini-sing
- addDnsServer: 172.19.0.1 → 198.18.0.1 (TUN local address can't be DNS target — gvisor doesn't route packets to self through the stack) - duplicateTunFdForEngine: dup() fd for Go so Java and Go each own their fd independently, no double-close on stop/switch - SingConfig: add auto_route/strict_route/exclude_outbound, rule set coverage dedup, ads block opt-in toggle, PreferIPv4 - NodeTester: filter unsupported outbound types, fix interval type
This commit is contained in:
9026
app/src/main/assets/rules/cn-ip.json
Normal file
9026
app/src/main/assets/rules/cn-ip.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,11 +27,15 @@ object NodeTester {
|
||||
var isRunning = false
|
||||
private set
|
||||
|
||||
private fun supportsHelperOutbound(node: Node): Boolean {
|
||||
return node.type in setOf("shadowsocks", "vmess", "vless", "trojan")
|
||||
}
|
||||
|
||||
/** Start the helper process with all nodes configured */
|
||||
fun start(ctx: Context) {
|
||||
if (isRunning) return
|
||||
|
||||
val nodes = NodeStore.getAll(ctx)
|
||||
val nodes = NodeStore.getAll(ctx).filter(::supportsHelperOutbound)
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
val configFile = generateHelperConfig(ctx, nodes)
|
||||
@@ -160,7 +164,7 @@ object NodeTester {
|
||||
put("tag", GROUP_TAG)
|
||||
put("outbounds", memberTags)
|
||||
put("url", "https://www.gstatic.com/generate_204")
|
||||
put("interval", "5m")
|
||||
put("interval", 300)
|
||||
})
|
||||
// direct fallback
|
||||
outbounds.put(JSONObject().apply {
|
||||
@@ -194,7 +198,7 @@ object NodeTester {
|
||||
|
||||
// Log
|
||||
config.put("log", JSONObject().apply {
|
||||
put("level", "warn")
|
||||
put("level", "trace")
|
||||
put("output", File(ctx.filesDir, "sing-box-helper.log").absolutePath)
|
||||
put("timestamp", true)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ class SettingsFragment : Fragment() {
|
||||
private lateinit var etDnsLocal: TextInputEditText
|
||||
private lateinit var swAutoconnect: SwitchMaterial
|
||||
private lateinit var swIpv6: SwitchMaterial
|
||||
private lateinit var swAdsBlock: SwitchMaterial
|
||||
private lateinit var etProxyDomains: TextInputEditText
|
||||
private lateinit var etDirectDomains: TextInputEditText
|
||||
private lateinit var etBlockDomains: TextInputEditText
|
||||
@@ -49,6 +50,7 @@ class SettingsFragment : Fragment() {
|
||||
etDnsLocal = view.findViewById(R.id.et_dns_local)
|
||||
swAutoconnect = view.findViewById(R.id.sw_autoconnect)
|
||||
swIpv6 = view.findViewById(R.id.sw_ipv6)
|
||||
swAdsBlock = view.findViewById(R.id.sw_ads_block)
|
||||
etProxyDomains = view.findViewById(R.id.et_proxy_domains)
|
||||
etDirectDomains = view.findViewById(R.id.et_direct_domains)
|
||||
etBlockDomains = view.findViewById(R.id.et_block_domains)
|
||||
@@ -65,6 +67,7 @@ class SettingsFragment : Fragment() {
|
||||
etDnsLocal.setText(prefs.getString("dns_local", "223.5.5.5"))
|
||||
swAutoconnect.isChecked = prefs.getBoolean("autoconnect", false)
|
||||
swIpv6.isChecked = prefs.getBoolean("ipv6", false)
|
||||
swAdsBlock.isChecked = prefs.getBoolean("ads_block_enabled", false)
|
||||
spRoutingMode.setSelection(prefs.getInt("routing_mode", 0))
|
||||
etProxyDomains.setText(prefs.getString("proxy_domains", ""))
|
||||
etDirectDomains.setText(prefs.getString("direct_domains", ""))
|
||||
@@ -80,6 +83,9 @@ class SettingsFragment : Fragment() {
|
||||
swIpv6.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("ipv6", checked).apply()
|
||||
}
|
||||
swAdsBlock.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("ads_block_enabled", checked).apply()
|
||||
}
|
||||
swTailscale.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("tailscale_enabled", checked).apply()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
|
||||
object SingConfig {
|
||||
|
||||
@@ -19,10 +20,8 @@ object SingConfig {
|
||||
val assets = ctx.assets.list("rules") ?: return rulesDir
|
||||
for (name in assets) {
|
||||
val dest = File(rulesDir, name)
|
||||
if (!dest.exists()) {
|
||||
ctx.assets.open("rules/$name").use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
ctx.assets.open("rules/$name").use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
return rulesDir
|
||||
@@ -36,6 +35,13 @@ object SingConfig {
|
||||
val ipCidrs: List<String> = emptyList()
|
||||
)
|
||||
|
||||
private data class CoverageIndex(
|
||||
val domains: MutableSet<String> = linkedSetOf(),
|
||||
val domainSuffixes: MutableSet<String> = linkedSetOf(),
|
||||
val domainKeywords: MutableSet<String> = linkedSetOf(),
|
||||
val ipCidrs: MutableList<Pair<InetAddress, Int>> = mutableListOf()
|
||||
)
|
||||
|
||||
private fun loadRuleSet(rulesDir: File, name: String): RuleSetData {
|
||||
val file = File(rulesDir, name)
|
||||
if (!file.exists()) return RuleSetData()
|
||||
@@ -74,10 +80,97 @@ object SingConfig {
|
||||
return RuleSetData(domains, suffixes, keywords, cidrs)
|
||||
}
|
||||
|
||||
private fun loadPreferredRuleSet(rulesDir: File, preferred: String, fallbackNames: List<String>): RuleSetData {
|
||||
val preferredData = loadRuleSet(rulesDir, preferred)
|
||||
return if (preferredData.isEmpty()) loadMultipleRuleSets(rulesDir, fallbackNames) else preferredData
|
||||
}
|
||||
|
||||
private fun RuleSetData.isEmpty(): Boolean {
|
||||
return domains.isEmpty() && domainSuffixes.isEmpty() && domainKeywords.isEmpty() && ipCidrs.isEmpty()
|
||||
}
|
||||
|
||||
private fun RuleSetData.filterCoveredBy(index: CoverageIndex): RuleSetData {
|
||||
val keptDomains = domains.filterNot { index.coversDomain(it) }
|
||||
val keptSuffixes = domainSuffixes.filterNot { index.coversSuffix(it) }
|
||||
val keptKeywords = domainKeywords.filterNot { index.domainKeywords.contains(it) }
|
||||
val keptCidrs = ipCidrs.filterNot { index.coversCidr(it) }
|
||||
return RuleSetData(
|
||||
domains = keptDomains.distinct(),
|
||||
domainSuffixes = keptSuffixes.distinct(),
|
||||
domainKeywords = keptKeywords.distinct(),
|
||||
ipCidrs = keptCidrs.distinct()
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoverageIndex.addAll(data: RuleSetData) {
|
||||
domains.addAll(data.domains)
|
||||
domainSuffixes.addAll(data.domainSuffixes)
|
||||
domainKeywords.addAll(data.domainKeywords)
|
||||
data.ipCidrs.mapNotNullTo(ipCidrs) { parseCidr(it) }
|
||||
}
|
||||
|
||||
private fun CoverageIndex.coversDomain(domain: String): Boolean {
|
||||
if (domains.contains(domain)) return true
|
||||
if (coversSuffix(domain)) return true
|
||||
return domainKeywords.any { keyword -> domain.contains(keyword) }
|
||||
}
|
||||
|
||||
private fun CoverageIndex.coversSuffix(suffix: String): Boolean {
|
||||
if (domainSuffixes.contains(suffix)) return true
|
||||
return suffix.split(".").drop(1).any { parent -> domainSuffixes.contains(parent) } ||
|
||||
domainKeywords.any { keyword -> suffix.contains(keyword) }
|
||||
}
|
||||
|
||||
private fun CoverageIndex.coversCidr(cidr: String): Boolean {
|
||||
val target = parseCidr(cidr) ?: return false
|
||||
return ipCidrs.any { parent -> containsRange(parent, target) }
|
||||
}
|
||||
|
||||
private fun parseCidr(cidr: String): Pair<InetAddress, Int>? {
|
||||
val parts = cidr.split("/", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
return try {
|
||||
InetAddress.getByName(parts[0]) to parts[1].toInt()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsRange(parent: Pair<InetAddress, Int>, child: Pair<InetAddress, Int>): Boolean {
|
||||
val (parentIp, parentPrefix) = parent
|
||||
val (childIp, childPrefix) = child
|
||||
val parentBytes = parentIp.address
|
||||
val childBytes = childIp.address
|
||||
if (parentBytes.size != childBytes.size) return false
|
||||
if (parentPrefix > childPrefix) return false
|
||||
val fullBytes = parentPrefix / 8
|
||||
val partialBits = parentPrefix % 8
|
||||
for (i in 0 until fullBytes) {
|
||||
if (parentBytes[i] != childBytes[i]) return false
|
||||
}
|
||||
if (partialBits == 0) return true
|
||||
val mask = (0xFF shl (8 - partialBits)) and 0xFF
|
||||
return (parentBytes[fullBytes].toInt() and mask) == (childBytes[fullBytes].toInt() and mask)
|
||||
}
|
||||
|
||||
private fun resolveRuleSets(
|
||||
directRules: RuleSetData,
|
||||
blockRules: RuleSetData,
|
||||
proxyRules: RuleSetData
|
||||
): Triple<RuleSetData, RuleSetData, RuleSetData> {
|
||||
val directIndex = CoverageIndex().apply { addAll(directRules) }
|
||||
val filteredBlockRules = blockRules.filterCoveredBy(directIndex)
|
||||
val combinedIndex = CoverageIndex().apply {
|
||||
addAll(directRules)
|
||||
addAll(filteredBlockRules)
|
||||
}
|
||||
val filteredProxyRules = proxyRules.filterCoveredBy(combinedIndex)
|
||||
return Triple(directRules, filteredBlockRules, filteredProxyRules)
|
||||
}
|
||||
|
||||
/** Build a route rule JSONObject from RuleSetData */
|
||||
private fun ruleSetToRouteRule(data: RuleSetData, outbound: String): JSONObject? {
|
||||
if (data.domains.isEmpty() && data.domainSuffixes.isEmpty() &&
|
||||
data.domainKeywords.isEmpty() && data.ipCidrs.isEmpty()) return null
|
||||
if (data.isEmpty()) return null
|
||||
return JSONObject().apply {
|
||||
if (data.domains.isNotEmpty()) put("domain", JSONArray(data.domains))
|
||||
if (data.domainSuffixes.isNotEmpty()) put("domain_suffix", JSONArray(data.domainSuffixes))
|
||||
@@ -92,6 +185,7 @@ object SingConfig {
|
||||
val remoteDns = prefs.getString("dns_remote", "8.8.8.8") ?: "8.8.8.8"
|
||||
val localDns = prefs.getString("dns_local", "223.5.5.5") ?: "223.5.5.5"
|
||||
val routingMode = prefs.getInt("routing_mode", ROUTE_RULE)
|
||||
val adsBlockEnabled = prefs.getBoolean("ads_block_enabled", false)
|
||||
val proxyDomains = prefs.getString("proxy_domains", "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList()
|
||||
val directDomains = prefs.getString("direct_domains", "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList()
|
||||
val blockDomains = prefs.getString("block_domains", "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList()
|
||||
@@ -102,17 +196,20 @@ object SingConfig {
|
||||
|
||||
// Load rule sets from assets
|
||||
val rulesDir = ensureRuleSets(ctx)
|
||||
val cnDirectRules = loadMultipleRuleSets(rulesDir, listOf(
|
||||
val cnDirectRules = loadPreferredRuleSet(rulesDir, "resolved-direct.json", listOf(
|
||||
"cn-direct.json", "bilibili.json", "douyin.json", "baidu.json",
|
||||
"taobao.json", "wechat.json", "jd.json", "weibo.json", "zhihu.json",
|
||||
"netease-music.json", "iqiyi.json", "cn-media.json", "meituan.json"
|
||||
"netease-music.json", "iqiyi.json", "cn-media.json", "meituan.json",
|
||||
"cn-ip.json"
|
||||
))
|
||||
val adsRules = loadRuleSet(rulesDir, "ads.json")
|
||||
val proxyRules = loadMultipleRuleSets(rulesDir, listOf(
|
||||
val adsRules = loadPreferredRuleSet(rulesDir, "resolved-reject.json", listOf("ads.json"))
|
||||
val proxyRules = loadPreferredRuleSet(rulesDir, "resolved-proxy.json", listOf(
|
||||
"telegram.json", "google.json", "youtube.json", "netflix.json",
|
||||
"openai.json", "tiktok.json", "github.json", "facebook.json",
|
||||
"twitter.json", "amazon.json", "steam.json"
|
||||
))
|
||||
val (resolvedDirectRules, resolvedAdsRules, resolvedProxyRules) =
|
||||
resolveRuleSets(cnDirectRules, adsRules, proxyRules)
|
||||
|
||||
val config = JSONObject()
|
||||
|
||||
@@ -181,6 +278,9 @@ object SingConfig {
|
||||
put("address", JSONArray().put("172.19.0.1/30"))
|
||||
put("mtu", 1500)
|
||||
put("stack", "gvisor")
|
||||
put("auto_route", true)
|
||||
put("strict_route", true)
|
||||
put("exclude_outbound", true)
|
||||
put("sniff", true)
|
||||
put("sniff_override_destination", true)
|
||||
}))
|
||||
@@ -263,12 +363,14 @@ object SingConfig {
|
||||
put("outbound", "tailnet")
|
||||
})
|
||||
}
|
||||
// Ads block (from rule sets)
|
||||
ruleSetToRouteRule(adsRules, "block")?.let { put(it) }
|
||||
// Ads block is opt-in from settings to avoid surprising default breakage.
|
||||
if (adsBlockEnabled) {
|
||||
ruleSetToRouteRule(resolvedAdsRules, "block")?.let { put(it) }
|
||||
}
|
||||
// Proxy services (from rule sets)
|
||||
ruleSetToRouteRule(proxyRules, "proxy")?.let { put(it) }
|
||||
ruleSetToRouteRule(resolvedProxyRules, "proxy")?.let { put(it) }
|
||||
// CN domains direct (from rule sets)
|
||||
ruleSetToRouteRule(cnDirectRules, "direct")?.let { put(it) }
|
||||
ruleSetToRouteRule(resolvedDirectRules, "direct")?.let { put(it) }
|
||||
// .cn suffix fallback
|
||||
put(JSONObject().apply {
|
||||
put("domain_suffix", JSONArray().put(".cn"))
|
||||
@@ -292,7 +394,7 @@ object SingConfig {
|
||||
})
|
||||
|
||||
config.put("log", JSONObject().apply {
|
||||
put("level", "info")
|
||||
put("level", "trace")
|
||||
put("output", File(ctx.filesDir, "sing-box.log").absolutePath)
|
||||
put("timestamp", true)
|
||||
})
|
||||
|
||||
@@ -29,6 +29,11 @@ class SingVpnService : VpnService() {
|
||||
|
||||
private var tunFd: ParcelFileDescriptor? = null
|
||||
|
||||
private fun duplicateTunFdForEngine(): Int {
|
||||
val tun = tunFd ?: error("TUN not established")
|
||||
return ParcelFileDescriptor.dup(tun.fileDescriptor).detachFd()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
@@ -84,7 +89,7 @@ class SingVpnService : VpnService() {
|
||||
.setSession("Sing VPN")
|
||||
.addAddress("172.19.0.1", 30)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
.addDnsServer("1.1.1.1")
|
||||
.addDnsServer("198.18.0.1")
|
||||
.setMtu(1500)
|
||||
.establish()
|
||||
|
||||
@@ -100,7 +105,7 @@ class SingVpnService : VpnService() {
|
||||
MiniSing.setVpnService(this)
|
||||
|
||||
// Generate config and start mini-sing in-process
|
||||
val configFile = SingConfig.generate(this, node, tun.fd)
|
||||
val configFile = SingConfig.generate(this, node, duplicateTunFdForEngine())
|
||||
val configJSON = configFile.readText()
|
||||
|
||||
// Write log path into a file mini-sing can read
|
||||
@@ -124,7 +129,6 @@ class SingVpnService : VpnService() {
|
||||
|
||||
private fun switchNode() {
|
||||
if (!isRunning) return
|
||||
val fd = tunFd?.fd ?: return
|
||||
|
||||
Thread {
|
||||
val node = NodeStore.getActive(this)
|
||||
@@ -137,7 +141,7 @@ class SingVpnService : VpnService() {
|
||||
// Stop current, start new with same TUN fd
|
||||
MiniSing.stop()
|
||||
|
||||
val configFile = SingConfig.generate(this, node, fd)
|
||||
val configFile = SingConfig.generate(this, node, duplicateTunFdForEngine())
|
||||
val error = MiniSing.start(configFile.readText())
|
||||
if (error != null) {
|
||||
Log.e(TAG, "Failed to restart after switch: $error")
|
||||
@@ -164,6 +168,8 @@ class SingVpnService : VpnService() {
|
||||
|
||||
MiniSing.stop()
|
||||
|
||||
// The duplicated fd passed to mini-sing is owned by Go and closed there.
|
||||
// Java only closes the original VpnService ParcelFileDescriptor here.
|
||||
try { tunFd?.close() } catch (_: Exception) {}
|
||||
tunFd = null
|
||||
|
||||
|
||||
@@ -126,6 +126,15 @@
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/sw_ads_block"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/settings_ads_block"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary" />
|
||||
|
||||
<!-- Custom domains -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
<string name="settings_general_label">General</string>
|
||||
<string name="settings_autoconnect">Auto-connect on launch</string>
|
||||
<string name="settings_ipv6">Enable IPv6</string>
|
||||
<string name="settings_ads_block">Enable built-in block rules</string>
|
||||
<string name="settings_custom_rules_label">Custom Rules</string>
|
||||
<string name="settings_proxy_domains_hint">Force proxy (one keyword per line)</string>
|
||||
<string name="settings_direct_domains_hint">Force direct (one keyword per line)</string>
|
||||
|
||||
Reference in New Issue
Block a user