diff --git a/.gitignore b/.gitignore index f2849b371..c865f7128 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ buildSrc/build jniLibs/ /library/libcore_build/ /libcore/.build/ +/libcore/version_gen.go /.masterdnsvpn-build/ # Local config / generated app artifacts diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index a63c8e33e..de5050e9f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -205,6 +205,7 @@ object Key { const val SUBSCRIPTION_AUTO_UPDATE_DELAY = "subscriptionAutoUpdateDelay" const val SUBSCRIPTION_FILTER_MODE = "subscriptionFilterMode" const val SUBSCRIPTION_FILTER_REGEX = "subscriptionFilterRegex" + const val SUBSCRIPTION_CUSTOM_DNS = "subscriptionCustomDns" // diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index 777f35a19..38199939c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -365,6 +365,7 @@ object DataStore : OnPreferenceDataStoreChangeListener { var subscriptionAutoUpdateDelay by profileCacheStore.stringToInt(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY) { 360 } var subscriptionFilterMode by profileCacheStore.stringToInt(Key.SUBSCRIPTION_FILTER_MODE) { 0 } var subscriptionFilterRegex by profileCacheStore.string(Key.SUBSCRIPTION_FILTER_REGEX) + var subscriptionCustomDns by profileCacheStore.string(Key.SUBSCRIPTION_CUSTOM_DNS) var rulesFirstCreate by profileCacheStore.boolean("rulesFirstCreate") diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java b/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java index a4570d554..3cfbcf10a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java @@ -25,6 +25,10 @@ public class SubscriptionBean extends Serializable { public Integer filterMode; public String filterRegex; + // Optional resolver used ONLY for this subscription's server domains. + // Empty/null = unset (global DNS is used, unchanged behavior). + public String customDnsResolver; + // SIP008 public Long bytesUsed; @@ -46,7 +50,7 @@ public SubscriptionBean() { @Override public void serializeToBuffer(ByteBufferOutput output) { - output.writeInt(2); + output.writeInt(3); output.writeInt(type); @@ -65,6 +69,9 @@ public void serializeToBuffer(ByteBufferOutput output) { // v2 output.writeInt(filterMode); output.writeString(filterRegex); + + // v3 + output.writeString(customDnsResolver); } public void serializeForShare(ByteBufferOutput output) { @@ -100,6 +107,11 @@ public void deserializeFromBuffer(ByteBufferInput input) { filterMode = input.readInt(); filterRegex = input.readString(); } + + // v3 + if (version >= 3) { + customDnsResolver = input.readString(); + } } public void deserializeFromShare(ByteBufferInput input) { @@ -127,6 +139,7 @@ public void initializeDefaultValues() { if (lastUpdated == null) lastUpdated = 0; if (filterMode == null) filterMode = 0; if (filterRegex == null) filterRegex = ""; + if (customDnsResolver == null) customDnsResolver = ""; if (bytesUsed == null) bytesUsed = 0L; if (bytesRemaining == null) bytesRemaining = 0L; diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index c2b3c40b8..5f41e8b8a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -7,6 +7,8 @@ import io.nekohasekai.sagernet.bg.VpnService import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG +import io.nekohasekai.sagernet.database.ProxyGroup +import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean @@ -54,6 +56,23 @@ private fun sanitizeDnsEntry(value: String): String { return value.filterNot { it.isISOControl() }.trim() } +// Extract the server hostname for a bean, mirroring the bypassDNSBeans logic +// (ConfigBean stores its address inside the parsed JSON "server" field). Falls back to +// bean.serverAddress when the JSON has no usable "server" so custom-resolver host mapping +// is preserved for those configs. +private fun serverHostOf(bean: AbstractBean): String? { + val fallback = bean.serverAddress?.takeIf { it.isNotBlank() } + if (bean is ConfigBean) { + return try { + val map = gson.fromJson(bean.config, mutableMapOf().javaClass) + map["server"]?.toString()?.takeIf { it.isNotBlank() } ?: fallback + } catch (_: Exception) { + fallback + } + } + return fallback +} + const val TAG_MIXED = "mixed-in" const val TAG_PROXY = "proxy" @@ -156,6 +175,21 @@ fun buildConfig( val userDNSRuleList = mutableListOf() val domainListDNSDirectForce = mutableListOf() val bypassDNSBeans = hashSetOf() + // Per-subscription custom resolver (#71). Maps a subscription group's resolver to the + // exact server hostnames imported from that subscription, so the resolver is used ONLY + // for those domains. + val perGroupResolver = HashMap() // groupId -> resolver address + val perGroupServerHosts = HashMap>() // groupId -> server hosts + // host -> set of resolver addresses requested for it (across all custom-resolver groups). + // A host is only routed to a custom resolver when it maps to exactly one resolver; hosts + // claimed by multiple subscriptions with different resolvers are ambiguous and fall back + // to the global DNS. + val hostResolvers = HashMap>() + // Every final-hop server host that does NOT belong to a custom-resolver subscription. + // If a host appears here too, it is shared with a normal profile, so we must NOT hijack + // it onto a per-subscription resolver (keep it on the global direct path). + val nonCustomFinalHosts = hashSetOf() + val groupCache = HashMap() // groupId -> group (build-time cache) val isVPN = DataStore.serviceMode == Key.MODE_VPN val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST // Whether the local mixed (SOCKS/HTTP) inbound is present in the final config. @@ -365,6 +399,53 @@ fun buildConfig( needGlobal = true tagOut = "g-" + proxyEntity.id bypassDNSBeans += proxyEntity.requireBean() + + // Per-subscription custom resolver (#71). The resolver belongs to the + // subscription that owns this chain (entity.groupId), not necessarily the + // last hop's group (a front/landing proxy may come from another group). + // We record the server hostnames of the chain hops that belong to that + // same subscription, so the resolver is scoped to its own servers only. + if (!forTest) { + val ownerGid = entity.groupId + val ownerGroup = groupCache.getOrPut(ownerGid) { + SagerDatabase.groupDao.getById(ownerGid) + } + val resolver = ownerGroup + ?.takeIf { it.type == GroupType.SUBSCRIPTION } + ?.subscription?.customDnsResolver + ?.let { sanitizeDnsEntry(it) } + ?.takeIf { it.isNotBlank() } + + if (resolver != null) { + // Record hosts of every chain hop owned by this subscription. + profileList.forEach { hop -> + if (hop.groupId == ownerGid) { + val host = serverHostOf(hop.requireBean()) + if (host != null && !host.isIpAddress()) { + perGroupResolver[ownerGid] = resolver + perGroupServerHosts.getOrPut(ownerGid) { mutableSetOf() } + .add(host) + hostResolvers.getOrPut(host) { mutableSetOf() }.add(resolver) + } + } else { + // hop from another (non-custom) group sharing this chain + val host = serverHostOf(hop.requireBean()) + if (host != null && !host.isIpAddress()) { + nonCustomFinalHosts.add(host) + } + } + } + } else { + // No custom resolver for this chain: none of its hosts (any hop) + // must be hijacked by another subscription's resolver. + profileList.forEach { hop -> + val host = serverHostOf(hop.requireBean()) + if (host != null && !host.isIpAddress()) { + nonCustomFinalHosts.add(host) + } + } + } + } } if (index == 0) { @@ -846,6 +927,13 @@ fun buildConfig( outbounds.add(fragmentOutbound) } + // Per-subscription custom resolver (#71): a host is eligible for a dedicated resolver + // only when it maps to exactly one resolver AND is not shared with any non-custom + // profile. Otherwise it stays on the global direct DNS path. + fun isExclusiveCustomHost(host: String): Boolean { + return hostResolvers[host]?.size == 1 && !nonCustomFinalHosts.contains(host) + } + // Bypass Lookup for the first profile bypassDNSBeans.forEach { var serverAddr = it.serverAddress @@ -859,7 +947,12 @@ fun buildConfig( } if (!serverAddr.isIpAddress()) { - domainListDNSDirectForce.add("full:${serverAddr}") + // Servers handled by a dedicated per-subscription resolver are kept out of the + // global direct-DNS force list to avoid conflicting routing (#71). Only do this + // for hosts exclusive to a single custom-resolver subscription. + if (!isExclusiveCustomHost(serverAddr)) { + domainListDNSDirectForce.add("full:${serverAddr}") + } } } @@ -970,6 +1063,35 @@ fun buildConfig( server = "dns-direct" }) } + + // Per-subscription custom resolver (#71): one DNS server per subscription group, + // routed directly (TAG_DIRECT) so it never loops through the proxy outbound, and + // a DNS rule matching ONLY that subscription's server domains. Inserted at the top + // so it takes precedence over the global direct-DNS force rule above. Hosts shared + // by multiple subscriptions with different resolvers are ambiguous and are skipped + // here (they stay on the global direct path). + perGroupResolver.forEach { (gid, resolver) -> + val hosts = perGroupServerHosts[gid] + ?.filter { it.isNotBlank() && isExclusiveCustomHost(it) } + ?.map { "full:$it" } + if (hosts.isNullOrEmpty()) return@forEach + + val serverTag = "dns-sub-$gid" + dns.servers.add(DNSServerOptions().apply { + address = resolver + tag = serverTag + detour = TAG_DIRECT + address_resolver = "dns-local" + // Reached via direct detour, so use the direct domain strategy (like + // dns-direct), not the server strategy (the tag would otherwise fall into + // the "server" arm of domainStrategy). + strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("dns-direct")) + }) + dns.rules.add(0, DNSRule_DefaultOptions().apply { + makeSingBoxRule(hosts) + server = serverTag + }) + } } if (!forTest) _hack_custom_config = DataStore.globalCustomConfig diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt index 37f87b924..5d2890909 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt @@ -67,6 +67,7 @@ class GroupSettingsActivity( DataStore.subscriptionAutoUpdateDelay = subscription.autoUpdateDelay DataStore.subscriptionFilterMode = subscription.filterMode DataStore.subscriptionFilterRegex = subscription.filterRegex + DataStore.subscriptionCustomDns = subscription.customDnsResolver ?: "" } fun ProxyGroup.serialize() { @@ -100,6 +101,7 @@ class GroupSettingsActivity( autoUpdateDelay = DataStore.subscriptionAutoUpdateDelay filterMode = DataStore.subscriptionFilterMode filterRegex = DataStore.subscriptionFilterRegex + customDnsResolver = DataStore.subscriptionCustomDns } } } @@ -206,6 +208,28 @@ class GroupSettingsActivity( updateFilterMode((newValue as String).toInt()) true } + + val subscriptionCustomDns = + findPreference(Key.SUBSCRIPTION_CUSTOM_DNS)!! + subscriptionCustomDns.setOnPreferenceChangeListener { pref, newValue -> + val value = (newValue as String).trim() + if (isValidCustomDnsResolver(value)) { + // Persist the normalized (trimmed) value rather than the raw input. + if (value != newValue) { + (pref as EditTextPreference).text = value + false + } else { + true + } + } else { + Toast.makeText( + requireContext(), + R.string.subscription_custom_dns_invalid, + Toast.LENGTH_LONG, + ).show() + false + } + } } class UnsavedChangesDialogFragment : AlertDialogFragment() { @@ -450,3 +474,30 @@ class GroupSettingsActivity( } } + +/** + * Validate a per-subscription custom resolver value. + * Empty = unset (allowed). Otherwise must be one of: + * - a URL with scheme https/tls/quic and a non-empty host + * - a bare IPv4/IPv6 literal or host[:port] + * No default is implied; sing-box performs final parsing at runtime. + */ +private fun isValidCustomDnsResolver(raw: String): Boolean { + val value = raw.trim() + if (value.isEmpty()) return true + if (value.any { it.isISOControl() || it.isWhitespace() }) return false + + if (value.contains("://")) { + val scheme = value.substringBefore("://").lowercase() + if (scheme !in setOf("https", "tls", "quic")) return false + val rest = value.substringAfter("://") + val host = rest.substringBefore("/").substringBefore("?") + // strip optional [ipv6] / host:port; require a non-empty host + val bare = host.substringBeforeLast(":").trim('[', ']') + return bare.isNotEmpty() + } + + // bare host[:port] / ip[:port] + val host = value.substringBeforeLast(":").trim('[', ']') + return host.isNotEmpty() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 759b7125d..a8719e289 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,6 +403,9 @@ Include Exclude Filter (Regex) + Custom resolver + Optional DNS used only to resolve this subscription\'s server domains. Leave empty to use the global DNS. Accepts https://, tls://, quic:// or host[:port]. + Invalid resolver. Use https://, tls://, quic:// or host[:port], or leave empty. Raw Update Settings Auto Update diff --git a/app/src/main/res/xml/group_preferences.xml b/app/src/main/res/xml/group_preferences.xml index 30935fcb6..4d3626bda 100644 --- a/app/src/main/res/xml/group_preferences.xml +++ b/app/src/main/res/xml/group_preferences.xml @@ -78,6 +78,13 @@ app:title="@string/filter_regex" app:useSimpleSummaryProvider="true" /> + + /dev/null || true + SING_BOX_VERSION="${VERSION_SING_BOX:-}" +fi +# Fallbacks if the pin is missing: read_tag, then git describe. +if [ -z "$SING_BOX_VERSION" ] && [ -n "$SING_BOX_DIR" ]; then + git -C "$SING_BOX_DIR" fetch --tags --force origin 2>/dev/null || true + SING_BOX_VERSION="$(cd "$SING_BOX_DIR" && CGO_ENABLED=0 go run ./cmd/internal/read_tag 2>/dev/null || true)" + if [ -z "$SING_BOX_VERSION" ] || [ "$SING_BOX_VERSION" = "unknown" ]; then + SING_BOX_VERSION="$(git -C "$SING_BOX_DIR" describe --tags --always 2>/dev/null || true)" + fi +fi +# Remove any stale generated file so a failed lookup falls back to constant's default +# ("unknown") rather than a previous run's value. +rm -f version_gen.go +if [ -z "$SING_BOX_VERSION" ] || [ "$SING_BOX_VERSION" = "unknown" ]; then + echo ">> WARNING: could not determine sing-box version (dir='$SING_BOX_DIR'); About will show 'unknown'" +else + echo ">> sing-box version: $SING_BOX_VERSION (from '$SING_BOX_DIR')" + # Sanitize to a safe Go string literal (alnum, dot, dash, plus only). + SAFE_VERSION="$(printf '%s' "$SING_BOX_VERSION" | tr -cd 'A-Za-z0-9.+-')" + cat > version_gen.go <