Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ buildSrc/build
jniLibs/
/library/libcore_build/
/libcore/.build/
/libcore/version_gen.go
/.masterdnsvpn-build/

# Local config / generated app artifacts
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

//

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,7 +50,7 @@ public SubscriptionBean() {

@Override
public void serializeToBuffer(ByteBufferOutput output) {
output.writeInt(2);
output.writeInt(3);

output.writeInt(type);

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
124 changes: 123 additions & 1 deletion app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Any>().javaClass)
map["server"]?.toString()?.takeIf { it.isNotBlank() } ?: fallback
} catch (_: Exception) {
fallback
}
}
return fallback
}

const val TAG_MIXED = "mixed-in"

const val TAG_PROXY = "proxy"
Expand Down Expand Up @@ -156,6 +175,21 @@ fun buildConfig(
val userDNSRuleList = mutableListOf<DNSRule_DefaultOptions>()
val domainListDNSDirectForce = mutableListOf<String>()
val bypassDNSBeans = hashSetOf<AbstractBean>()
// 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<Long, String>() // groupId -> resolver address
val perGroupServerHosts = HashMap<Long, MutableSet<String>>() // 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<String, MutableSet<String>>()
// 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<String>()
val groupCache = HashMap<Long, ProxyGroup?>() // 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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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}")
}
}
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -100,6 +101,7 @@ class GroupSettingsActivity(
autoUpdateDelay = DataStore.subscriptionAutoUpdateDelay
filterMode = DataStore.subscriptionFilterMode
filterRegex = DataStore.subscriptionFilterRegex
customDnsResolver = DataStore.subscriptionCustomDns
}
}
}
Expand Down Expand Up @@ -206,6 +208,28 @@ class GroupSettingsActivity(
updateFilterMode((newValue as String).toInt())
true
}

val subscriptionCustomDns =
findPreference<EditTextPreference>(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<Empty, Empty>() {
Expand Down Expand Up @@ -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()
}
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@
<string name="filter_include">Include</string>
<string name="filter_exclude">Exclude</string>
<string name="filter_regex">Filter (Regex)</string>
<string name="subscription_custom_dns">Custom resolver</string>
<string name="subscription_custom_dns_sum">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].</string>
<string name="subscription_custom_dns_invalid">Invalid resolver. Use https://, tls://, quic:// or host[:port], or leave empty.</string>
<string name="raw">Raw</string>
<string name="update_settings">Update Settings</string>
<string name="auto_update">Auto Update</string>
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/xml/group_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
app:title="@string/filter_regex"
app:useSimpleSummaryProvider="true" />

<EditTextPreference
app:icon="@drawable/ic_action_dns"
app:key="subscriptionCustomDns"
app:summary="@string/subscription_custom_dns_sum"
app:title="@string/subscription_custom_dns"
app:useSimpleSummaryProvider="true" />

</PreferenceCategory>

<PreferenceCategory
Expand Down
2 changes: 1 addition & 1 deletion buildScript/lib/core/get_source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pushd sing-box
# Ensure we point at the hawkff fork (a cached checkout may predate the switch
# from starifly) and that the pinned commit is present before checking it out.
git remote set-url origin https://github.com/hawkff/sing-box.git
git fetch origin
git fetch origin --tags --force
git checkout "$COMMIT_SING_BOX"
popd

Expand Down
5 changes: 5 additions & 0 deletions buildScript/lib/core/get_source_env.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading