From b79310abac4f93c0e1c45b00f5d646dce40de15a Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:46:07 -0400 Subject: [PATCH 1/6] feat(#71): per-subscription DNS resolver + show sing-box version in About Per-subscription resolver: - Add optional customDnsResolver to SubscriptionBean (kryo v2->3, gated read; kept out of shared links). Wire DataStore key + group_preferences UI with strict validation (https/tls/quic scheme+host, or host[:port], or empty). - ConfigBuilder: for a subscription with a resolver set, emit a dedicated DNS server (detour=direct, address_resolver=dns-local) and a dns.rule matching ONLY that subscription's final-hop server domains, above the global force rule. Empty resolver = no change. Ambiguous shared hostnames (same domain, different resolvers) stay on the global direct path. No default/Google DNS. About version: - libcore/build.sh injects constant.Version via read_tag (git-describe fallback); get_source.sh fetches tags so read_tag resolves on CI. Fixes the About screen showing 'unknown'. --- .../java/io/nekohasekai/sagernet/Constants.kt | 1 + .../sagernet/database/DataStore.kt | 1 + .../sagernet/database/SubscriptionBean.java | 15 +++- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 85 ++++++++++++++++++- .../sagernet/ui/GroupSettingsActivity.kt | 51 +++++++++++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/group_preferences.xml | 7 ++ buildScript/lib/core/get_source.sh | 2 +- libcore/build.sh | 23 ++++- 9 files changed, 184 insertions(+), 4 deletions(-) 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..d976244f2 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,20 @@ 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). +private fun serverHostOf(bean: AbstractBean): String? { + if (bean is ConfigBean) { + return try { + val map = gson.fromJson(bean.config, mutableMapOf().javaClass) + map["server"]?.toString()?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + } + return bean.serverAddress?.takeIf { it.isNotBlank() } +} + const val TAG_MIXED = "mixed-in" const val TAG_PROXY = "proxy" @@ -156,6 +172,16 @@ 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. Populated for the final-hop (global) outbound of each chain. + val perGroupResolver = HashMap() // groupId -> resolver address + val perGroupServerHosts = HashMap>() // groupId -> server hosts + // host -> set of resolver addresses requested for it (across all groups). A host is only + // routed to a custom resolver when it maps to exactly one resolver; ambiguous hosts + // (same domain, different resolvers in different subscriptions) fall back to global DNS. + val hostResolvers = HashMap>() + 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 +391,28 @@ fun buildConfig( needGlobal = true tagOut = "g-" + proxyEntity.id bypassDNSBeans += proxyEntity.requireBean() + + // Per-subscription custom resolver (#71): associate this final-hop + // server's domain with its originating subscription's resolver (if any). + if (!forTest) { + val gid = proxyEntity.groupId + val grp = groupCache.getOrPut(gid) { + SagerDatabase.groupDao.getById(gid) + } + val resolver = grp + ?.takeIf { it.type == GroupType.SUBSCRIPTION } + ?.subscription?.customDnsResolver + ?.let { sanitizeDnsEntry(it) } + ?.takeIf { it.isNotBlank() } + if (resolver != null) { + val host = serverHostOf(bean) + if (host != null && !host.isIpAddress()) { + perGroupResolver[gid] = resolver + perGroupServerHosts.getOrPut(gid) { mutableSetOf() }.add(host) + hostResolvers.getOrPut(host) { mutableSetOf() }.add(resolver) + } + } + } } if (index == 0) { @@ -859,7 +907,16 @@ fun buildConfig( } if (!serverAddr.isIpAddress()) { - domainListDNSDirectForce.add("full:${serverAddr}") + // Servers belonging to a subscription with a custom resolver are handled + // by a dedicated per-subscription DNS server/rule below, so keep them out + // of the global direct-DNS force list to avoid conflicting routing (#71). + // Only do this for hosts that map to exactly ONE resolver; if the same + // hostname is claimed by multiple subscriptions with different resolvers, + // routing is ambiguous, so keep it on the global direct path instead. + val unambiguousCustom = hostResolvers[serverAddr]?.size == 1 + if (!unambiguousCustom) { + domainListDNSDirectForce.add("full:${serverAddr}") + } } } @@ -970,6 +1027,32 @@ 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() && hostResolvers[it]?.size == 1 } + ?.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" + strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) + }) + 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" /> + + > WARNING: could not determine sing-box version; About will show 'unknown'" +else + echo ">> sing-box version: $SING_BOX_VERSION" +fi +VERSION_LDFLAG="" +if [ -n "$SING_BOX_VERSION" ]; then + VERSION_LDFLAG="-X github.com/sagernet/sing-box/constant.Version=$SING_BOX_VERSION " +fi + # 16 KB page alignment (issue #1125): Android 15+ may use 16 KB memory pages, which # requires native .so LOAD segments aligned to 16384. Force the external linker to use a # 16 KB max/common page size so libgojni.so is aligned regardless of the gomobile/Go default. -"$GOPATH"/bin/gomobile-matsuri bind -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags='-s -w -extldflags=-Wl,-z,max-page-size=16384,-z,common-page-size=16384' -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 +"$GOPATH"/bin/gomobile-matsuri bind -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags="-s -w ${VERSION_LDFLAG}-extldflags=-Wl,-z,max-page-size=16384,-z,common-page-size=16384" -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 rm -r libcore-sources.jar proj=../app/libs From 1d146543c4edc0e222c37ec07bee7bac45d41c4f Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:50:50 -0400 Subject: [PATCH 2/6] fix(build): locate sing-box clone two levels up from libcore for version ldflag get_source.sh clones sing-box to the parent of the repo root; build.sh runs from libcore/, so the clone is ../../sing-box, not ../sing-box. Probe a few candidates so the constant.Version ldflag resolves on CI (the first build logged 'could not determine sing-box version'). --- libcore/build.sh | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/libcore/build.sh b/libcore/build.sh index f71157664..e0cf39c1f 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -17,19 +17,28 @@ fi export GOBIND=gobind-matsuri # Inject the real sing-box version so the About screen shows it instead of "unknown". -# Upstream's Makefile sets constant.Version via read_tag; mirror that here. The sing-box -# source is cloned to ../sing-box by buildScript/lib/core/get_source.sh. +# Upstream's Makefile sets constant.Version via read_tag; mirror that here. +# get_source.sh clones sing-box to the parent of the repo root; build.sh runs from +# libcore/, so the clone is two levels up (../../sing-box). Probe a few candidates so +# this works regardless of how the build is invoked. +SING_BOX_DIR="" +for cand in ../../sing-box ../sing-box ../../../sing-box; do + if [ -d "$cand" ] && [ -e "$cand/go.mod" ]; then + SING_BOX_DIR="$cand" + break + fi +done SING_BOX_VERSION="" -if [ -d ../sing-box ]; then - SING_BOX_VERSION="$(cd ../sing-box && CGO_ENABLED=0 go run ./cmd/internal/read_tag 2>/dev/null || true)" +if [ -n "$SING_BOX_DIR" ]; then + 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" ]; then - SING_BOX_VERSION="$(git -C ../sing-box describe --tags --always 2>/dev/null || true)" + SING_BOX_VERSION="$(git -C "$SING_BOX_DIR" describe --tags --always 2>/dev/null || true)" fi fi if [ -z "$SING_BOX_VERSION" ]; then - echo ">> WARNING: could not determine sing-box version; About will show 'unknown'" + echo ">> WARNING: could not determine sing-box version (dir='$SING_BOX_DIR'); About will show 'unknown'" else - echo ">> sing-box version: $SING_BOX_VERSION" + echo ">> sing-box version: $SING_BOX_VERSION (from '$SING_BOX_DIR')" fi VERSION_LDFLAG="" if [ -n "$SING_BOX_VERSION" ]; then From a12e5beedcc0675dab259f8d9fb7e938e62e31d9 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:03:58 -0400 Subject: [PATCH 3/6] fix(build): set sing-box version via generated Go file instead of ldflags gomobile bind does not reliably forward -ldflags -X to the gobind-generated package, so the previous ldflag approach left constant.Version='unknown' (verified: no version string in libgojni.so). Generate libcore/version_gen.go that sets constant.Version in init() when still default; compiled directly into libcore. version_gen.go is gitignored. --- .gitignore | 1 + libcore/build.sh | 31 +++++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 8 deletions(-) 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/libcore/build.sh b/libcore/build.sh index e0cf39c1f..77d29a59c 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -17,10 +17,12 @@ fi export GOBIND=gobind-matsuri # Inject the real sing-box version so the About screen shows it instead of "unknown". -# Upstream's Makefile sets constant.Version via read_tag; mirror that here. +# gomobile bind does not reliably forward `-ldflags -X` to the gobind-generated package +# (upstream NekoBox forks have long shown "unknown"), so instead generate a tiny Go file +# compiled into libcore that sets constant.Version at init time. Deterministic and not +# dependent on linker flag forwarding. # get_source.sh clones sing-box to the parent of the repo root; build.sh runs from -# libcore/, so the clone is two levels up (../../sing-box). Probe a few candidates so -# this works regardless of how the build is invoked. +# libcore/, so the clone is two levels up (../../sing-box). Probe a few candidates. SING_BOX_DIR="" for cand in ../../sing-box ../sing-box ../../../sing-box; do if [ -d "$cand" ] && [ -e "$cand/go.mod" ]; then @@ -35,20 +37,33 @@ if [ -n "$SING_BOX_DIR" ]; 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" ]; 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')" -fi -VERSION_LDFLAG="" -if [ -n "$SING_BOX_VERSION" ]; then - VERSION_LDFLAG="-X github.com/sagernet/sing-box/constant.Version=$SING_BOX_VERSION " + # 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 < Date: Wed, 24 Jun 2026 20:10:05 -0400 Subject: [PATCH 4/6] fix(build): treat read_tag 'unknown' as failure; fall back to git describe CI log showed 'sing-box version: unknown (from ../../sing-box)': read_tag prints the literal 'unknown' when it cannot resolve a tag, which bypassed the empty-string fallback. Now treat empty OR 'unknown' as failure and use git describe --tags --always, plus fetch tags before resolving. --- libcore/build.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libcore/build.sh b/libcore/build.sh index 77d29a59c..eaa5fbdff 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -32,15 +32,19 @@ for cand in ../../sing-box ../sing-box ../../../sing-box; do done SING_BOX_VERSION="" if [ -n "$SING_BOX_DIR" ]; then + # Ensure tags are present so read_tag / git describe can resolve a version. + 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" ]; then + # read_tag prints the literal "unknown" when it cannot resolve a tag; treat that + # (and empty) as failure and fall back to git describe. + 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" ]; then +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')" From a21112d00c7e643b7ebd3732dee0439366eb5e89 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:14:53 -0400 Subject: [PATCH 5/6] fix(build): pin sing-box version in get_source_env.sh (no tag dependency) CI git describe only resolved a bare hash (no tags in the runner's sing-box clone). Add explicit VERSION_SING_BOX pinned alongside COMMIT_SING_BOX as the deterministic source for the About version; read_tag/git describe remain as fallbacks. Update both together when bumping the core. --- buildScript/lib/core/get_source_env.sh | 5 +++++ libcore/build.sh | 12 ++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/buildScript/lib/core/get_source_env.sh b/buildScript/lib/core/get_source_env.sh index 012c71a34..4bec53d06 100644 --- a/buildScript/lib/core/get_source_env.sh +++ b/buildScript/lib/core/get_source_env.sh @@ -1,2 +1,7 @@ export COMMIT_SING_BOX="8966dca291128e4801b1492b0ae6e68cc7bea214" +# Human-readable sing-box version for the About screen. Pinned alongside the commit so the +# build does not depend on tags being present in the CI clone (git describe there only +# resolves a bare hash). Update this together with COMMIT_SING_BOX. Matches `git describe +# --tags` on the hawkff fork at the pinned commit. +export VERSION_SING_BOX="1.13.13-9-g8966dca2" export COMMIT_LIBNEKO="1c47a3af71990a7b2192e03292b4d246c308ef0b" diff --git a/libcore/build.sh b/libcore/build.sh index eaa5fbdff..55644238f 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -31,12 +31,16 @@ for cand in ../../sing-box ../sing-box ../../../sing-box; do fi done SING_BOX_VERSION="" -if [ -n "$SING_BOX_DIR" ]; then - # Ensure tags are present so read_tag / git describe can resolve a version. +# Preferred source: explicit pin in get_source_env.sh (deterministic, no tag dependency). +if [ -f ../buildScript/lib/core/get_source_env.sh ]; then + # shellcheck disable=SC1091 + source ../buildScript/lib/core/get_source_env.sh 2>/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)" - # read_tag prints the literal "unknown" when it cannot resolve a tag; treat that - # (and empty) as failure and fall back to git describe. 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 From 0013bd05a65c89f7fefe9d7e6ae40951a0f1e77b Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:26:26 -0400 Subject: [PATCH 6/6] fix(#71): scope per-sub resolver correctly (chain owner, shared/non-custom hosts) Address review findings: - serverHostOf: fall back to bean.serverAddress when ConfigBean JSON has no usable 'server' field. - Derive resolver ownership from the chain owner (entity.groupId), not the last hop's group, so subscriptions with a front/landing proxy still scope their resolver to their own server hosts. - Track all final-hop hosts of non-custom chains (every hop); only emit a per-subscription resolver rule for hosts exclusive to a single custom-resolver subscription (not shared with any normal profile), preventing DNS hijack. - Use the direct domain strategy for the per-sub resolver server (reached via direct detour), not the server strategy. --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 97 +++++++++++++------ 1 file changed, 68 insertions(+), 29 deletions(-) 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 d976244f2..5f41e8b8a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -57,17 +57,20 @@ private fun sanitizeDnsEntry(value: String): String { } // Extract the server hostname for a bean, mirroring the bypassDNSBeans logic -// (ConfigBean stores its address inside the parsed JSON "server" field). +// (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() } + map["server"]?.toString()?.takeIf { it.isNotBlank() } ?: fallback } catch (_: Exception) { - null + fallback } } - return bean.serverAddress?.takeIf { it.isNotBlank() } + return fallback } const val TAG_MIXED = "mixed-in" @@ -174,13 +177,18 @@ fun buildConfig( 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. Populated for the final-hop (global) outbound of each chain. + // 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 groups). A host is only - // routed to a custom resolver when it maps to exactly one resolver; ambiguous hosts - // (same domain, different resolvers in different subscriptions) fall back to global DNS. + // 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 @@ -392,24 +400,49 @@ fun buildConfig( tagOut = "g-" + proxyEntity.id bypassDNSBeans += proxyEntity.requireBean() - // Per-subscription custom resolver (#71): associate this final-hop - // server's domain with its originating subscription's resolver (if any). + // 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 gid = proxyEntity.groupId - val grp = groupCache.getOrPut(gid) { - SagerDatabase.groupDao.getById(gid) + val ownerGid = entity.groupId + val ownerGroup = groupCache.getOrPut(ownerGid) { + SagerDatabase.groupDao.getById(ownerGid) } - val resolver = grp + val resolver = ownerGroup ?.takeIf { it.type == GroupType.SUBSCRIPTION } ?.subscription?.customDnsResolver ?.let { sanitizeDnsEntry(it) } ?.takeIf { it.isNotBlank() } + if (resolver != null) { - val host = serverHostOf(bean) - if (host != null && !host.isIpAddress()) { - perGroupResolver[gid] = resolver - perGroupServerHosts.getOrPut(gid) { mutableSetOf() }.add(host) - hostResolvers.getOrPut(host) { mutableSetOf() }.add(resolver) + // 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) + } } } } @@ -894,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 @@ -907,14 +947,10 @@ fun buildConfig( } if (!serverAddr.isIpAddress()) { - // Servers belonging to a subscription with a custom resolver are handled - // by a dedicated per-subscription DNS server/rule below, so keep them out - // of the global direct-DNS force list to avoid conflicting routing (#71). - // Only do this for hosts that map to exactly ONE resolver; if the same - // hostname is claimed by multiple subscriptions with different resolvers, - // routing is ambiguous, so keep it on the global direct path instead. - val unambiguousCustom = hostResolvers[serverAddr]?.size == 1 - if (!unambiguousCustom) { + // 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}") } } @@ -1036,7 +1072,7 @@ fun buildConfig( // here (they stay on the global direct path). perGroupResolver.forEach { (gid, resolver) -> val hosts = perGroupServerHosts[gid] - ?.filter { it.isNotBlank() && hostResolvers[it]?.size == 1 } + ?.filter { it.isNotBlank() && isExclusiveCustomHost(it) } ?.map { "full:$it" } if (hosts.isNullOrEmpty()) return@forEach @@ -1046,7 +1082,10 @@ fun buildConfig( tag = serverTag detour = TAG_DIRECT address_resolver = "dns-local" - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) + // 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)