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:
Sing Dev
2026-04-02 02:41:59 +08:00
parent 60b3f7c04d
commit 789c0947ad
7 changed files with 9176 additions and 22 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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)
})

View File

@@ -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()
}

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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"

View File

@@ -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>