diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index 673b9778a..7b125f2b3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -58,6 +58,7 @@ object Key { const val APPEND_HTTP_PROXY = "appendHttpProxy" const val HTTP_PROXY_BYPASS = "httpProxyBypass" + const val DNS_HOSTS = "dnsHosts" const val STRICT_ROUTE = "strictRoute" const val CONNECTION_TEST_URL = "connectionTestURL" 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 67d9825ff..28f587bbb 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -253,6 +253,7 @@ object DataStore : OnPreferenceDataStoreChangeListener { var appendHttpProxy by configurationStore.boolean(Key.APPEND_HTTP_PROXY) var httpProxyBypass by configurationStore.string(Key.HTTP_PROXY_BYPASS) { DEFAULT_HTTP_PROXY_BYPASS } + var dnsHosts by configurationStore.string(Key.DNS_HOSTS) { "" } var strictRoute by configurationStore.boolean(Key.STRICT_ROUTE) { true } var connectionTestURL by configurationStore.string(Key.CONNECTION_TEST_URL) { CONNECTION_TEST_URL } var connectionTestConcurrent by configurationStore.int("connectionTestConcurrent") { 5 } 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 02719aa46..1816b170f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -39,6 +39,7 @@ import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.mkPort +import io.nekohasekai.sagernet.ktx.unwrapIPV6Host import io.nekohasekai.sagernet.utils.PackageCache import moe.matsuri.nb4a.* import moe.matsuri.nb4a.SingBoxOptions.* @@ -52,12 +53,93 @@ import moe.matsuri.nb4a.utils.JavaUtil.gson import moe.matsuri.nb4a.utils.Util import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.IDN import java.util.UUID private fun sanitizeDnsEntry(value: String): String { return value.filterNot { it.isISOControl() }.trim() } +// Validate a hosts address token strictly enough for sing-box's netip-based +// parser: the app-wide isIpAddress() regex is looser (it allows IPv4 leading +// zeros and malformed IPv6 forms) and one bad value would fail the whole config +// load. Embedded-IPv4 IPv6 forms (::ffff:1.2.3.4) are not supported; write the +// plain IPv4 address instead. Returns the address, or null when not usable. +private fun parseHostsAddress(token: String): String? { + val value = token.unwrapIPV6Host() + if (!value.isIpAddress()) return null + if (value.contains(':')) { + // Pure-hex IPv6 (the regex rejects embedded IPv4 forms). Reject the empty + // groups Go's netip parser refuses but the app regex tolerates: more than + // one "::" (a ":::" run counts twice via overlap), a bare leading or + // trailing ":", and more than 7 explicit groups alongside "::" (without + // "::" the regex already enforces exactly 8). + if (value.indexOf("::") != value.lastIndexOf("::")) return null + if (value.startsWith(":") && !value.startsWith("::")) return null + if (value.endsWith(":") && !value.endsWith("::")) return null + if (value.contains("::") && value.split(':').count { it.isNotEmpty() } > 7) return null + } else { + // IPv4: reject leading zeros, which Go's netip parser refuses. + if (value.split('.').any { it.length > 1 && it.startsWith('0') }) return null + } + return value +} + +private fun parseHostsDomain(token: String): String? { + var domain = sanitizeDnsEntry(token).removeSuffix(".") + if (domain.isEmpty()) return null + // Internationalized domains: convert to punycode, which is what arrives in + // actual DNS queries and what sing-box matches against. + if (domain.any { it.code >= 0x80 }) { + domain = try { + IDN.toASCII(domain) + } catch (_: IllegalArgumentException) { + return null + } + } + domain = domain.lowercase() + if (domain.isEmpty() || domain.isIpAddress() || domain.length > 253) return null + val labels = domain.split('.') + if (labels.any { it.isEmpty() || it.length > 63 }) return null + // Underscore is permitted: it is common in DNS names (service labels) even + // though it is invalid in strict hostnames. + if (labels.any { label -> + label.startsWith('-') || + label.endsWith('-') || + label.any { !(it in 'a'..'z' || it in '0'..'9' || it == '-' || it == '_') } + } + ) { + return null + } + return domain +} + +// Token separator for hosts entries: ASCII whitespace plus NBSP, which pasted web +// content often contains and which neither Java's \s (ASCII-only) nor the +// ISO-control sanitization covers. +private val hostsSeparator = "[\\s\u00A0]+".toRegex() + +// Parse the user DNS hosts rewrite list: one "domain ip [ip ...]" entry per line, +// separated by any whitespace. Blank lines, comments (#) and malformed lines are +// ignored instead of failing the config build. Non-IP tokens after the domain are +// dropped, and IPv6 addresses may be written with or without brackets. +internal fun parseDnsHosts(value: String): Map> { + val hosts = linkedMapOf>() + value.lineSequence().forEach { line -> + val tokens = line.split(hostsSeparator) + .map { sanitizeDnsEntry(it) } + .filter { it.isNotEmpty() } + if (tokens.size < 2 || tokens.first().startsWith("#")) return@forEach + val domain = parseHostsDomain(tokens.first()) ?: return@forEach + val addresses = tokens.drop(1) + .takeWhile { !it.startsWith("#") } + .mapNotNull { parseHostsAddress(it) } + if (addresses.isEmpty()) return@forEach + hosts.getOrPut(domain) { mutableListOf() }.addAll(addresses) + } + return hosts.mapValues { (_, addresses) -> addresses.distinct() } +} + // 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 @@ -82,6 +164,7 @@ const val TAG_DIRECT = "direct" const val TAG_BYPASS = "bypass" const val TAG_BLOCK = "block" const val TAG_FRAGMENT = "fragment" +const val TAG_DNS_HOSTS = "dns-hosts" const val LOCALHOST = "127.0.0.1" @@ -239,6 +322,7 @@ fun buildConfig(proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean .mapNotNull { dns -> sanitizeDnsEntry(dns).takeIf { it.isNotBlank() && !it.startsWith("#") } } val directDNS = DataStore.directDns.split("\n") .mapNotNull { dns -> sanitizeDnsEntry(dns).takeIf { it.isNotBlank() && !it.startsWith("#") } } + val dnsHosts = if (forTest) emptyMap() else parseDnsHosts(DataStore.dnsHosts) val enableDnsRouting = DataStore.enableDnsRouting val useFakeDns = DataStore.enableFakeDns && !forTest val needSniff = DataStore.trafficSniffing > 0 @@ -1140,6 +1224,36 @@ fun buildConfig(proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean }, ) } + // User DNS hosts rewrite: a hosts-type server answering A/AAAA from the + // predefined domain -> IP map. Scope the rule to configured domains only; + // otherwise sing-box's hosts transport may also consult implicit hosts-file + // data. Do not use ip_accept_any here: if the user maps only A records, + // an AAAA query should produce no answer instead of falling through to + // FakeDNS or remote DNS and bypassing the rewrite. + // Scoping to A/AAAA keeps other record types (TXT, MX, HTTPS/SVCB) on + // their normal resolution path, mirroring hosts-file semantics; SVCB + // answers may therefore still carry upstream address hints. + // Inserted below the loopback/force-bypass/per-subscription rules added + // after it, so proxy bootstrap resolution is never hijacked by user + // entries, and above the user DNS routing rules, so the rewrite wins + // for its configured domains. + if (dnsHosts.isNotEmpty()) { + dns.servers.add( + DNSServerOptions().apply { + tag = TAG_DNS_HOSTS + _hack_config_map["type"] = "hosts" + _hack_config_map["predefined"] = dnsHosts + }, + ) + dns.rules.add( + 0, + DNSRule_DefaultOptions().apply { + makeSingBoxRule(dnsHosts.keys.map { "full:$it" }) + query_type = listOf("A", "AAAA") + server = TAG_DNS_HOSTS + }, + ) + } // avoid loopback dns.rules.add( 0, diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt index 965a66def..a25d041d5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -46,9 +46,13 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { } } - private fun dnsReloadListener(preference: EditTextPreference, newValue: Any?): Boolean { + private fun dnsReloadListener( + preference: EditTextPreference, + newValue: Any?, + preprocess: (String) -> String = { it }, + ): Boolean { val rawValue = newValue as? String ?: return reloadListener.onPreferenceChange(preference, newValue) - val sanitizedValue = sanitizeDnsPreferenceValue(rawValue) + val sanitizedValue = sanitizeDnsPreferenceValue(preprocess(rawValue)) if (sanitizedValue != rawValue) { preference.text = sanitizedValue needReload() @@ -124,6 +128,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { val allowAccess = findPreference(Key.ALLOW_ACCESS)!! val appendHttpProxy = findPreference(Key.APPEND_HTTP_PROXY)!! val httpProxyBypass = findPreference(Key.HTTP_PROXY_BYPASS)!! + val dnsHosts = findPreference(Key.DNS_HOSTS)!! val strictRoute = findPreference(Key.STRICT_ROUTE)!! val showDirectSpeed = findPreference(Key.SHOW_DIRECT_SPEED)!! @@ -265,6 +270,34 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { concurrentDial.onPreferenceChangeListener = reloadListener enableFakeDns.onPreferenceChangeListener = reloadListener + dnsHosts.setOnBindEditTextListener { editText -> + editText.inputType = EditorInfo.TYPE_CLASS_TEXT or + EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE or + EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS + editText.minLines = 4 + editText.maxLines = 12 + editText.setHorizontallyScrolling(false) + } + // Concise summary: the hosts list can be long and multiline, so show a line + // count instead of dumping the raw value into the preference row. Comment + // lines are excluded so the number reflects entries, not text lines. + dnsHosts.summaryProvider = Preference.SummaryProvider { preference -> + val count = preference.text.orEmpty() + .lineSequence() + .map { it.trim() } + .count { it.isNotEmpty() && !it.startsWith("#") } + if (count == 0) { + preference.context.getString(R.string.not_set) + } else { + preference.context.resources.getQuantityString(R.plurals.dns_hosts_lines, count, count) + } + } + dnsHosts.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + // Tabs are valid separators in pasted hosts entries; convert them to + // spaces first so the control-character sanitization does not merge + // the domain and address tokens together. + dnsReloadListener(dnsHosts, newValue) { it.replace('\t', ' ') } + } remoteDns.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> dnsReloadListener(remoteDns, newValue) } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index f24149f5a..259744a20 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -91,6 +91,7 @@ فعال کردن FakeDNS ممکن است نیاز باشد که برنامه‌های دیگر را برای اتصال مجدد، دوباره راه‌اندازی کنید. بازنویسی دامنه + در هر خط یک مورد. خطوطی که با # شروع می‌شوند نظر هستند. مثال:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 پورت DNS محلی فعال کردن Transproxy ورودی حالت Transproxy @@ -294,6 +295,11 @@ اطمینان حاصل کنید که مستندات فنی را قبل از افزودن قوانین سفارشی مطالعه کرده‌اید، در غیر این صورت ممکن است نتوانید به اینترنت وصل شوید. %d خط + + %d خط + %d خط + + تنظیم نشده حالت تاریک مانند سیستم فعال diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f798a29dc..bd93e884f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -73,6 +73,7 @@ テスト用リンクを接続 ローカルDNSポート ドメインの書き換え + 1 行に 1 つ。# で始まる行はコメントです。例:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 プロキシが停止した後、ネットワークに再接続するために他のアプリケーションを再起動する必要がある場合があります。 FakeDNSを有効化 DNSルーティングを有効化 @@ -276,6 +277,10 @@ ライセンス カスタムルールを追加する前にドキュメントを必ずお読みください。さもないとインターネットに接続できなくなる場合があります。 %d 行 + + %d 行 + + 未設定 ナイトモード 言語 システムに従う diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index db408b542..eb73f5513 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -87,6 +87,7 @@ FakeDNS 활성화 프록시 중지 후 네트워크에 다시 연결하기 위해 다른 앱을 재시작해야 할 수 있습니다 도메인 재작성 + 한 줄에 하나씩 입력합니다. #으로 시작하면 주석입니다. 예:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 로컬 DNS 포트 Transproxy 인바운드 활성화 Transproxy 모드 @@ -270,6 +271,10 @@ 라이선스 사용자 지정 규칙을 추가하기 전에 설명서를 반드시 읽으세요. 그렇지 않으면 인터넷에 연결하지 못할 수 있습니다. %d줄 + + %d줄 + + 설정되지 않음 야간 모드 언어 시스템 설정 따르기 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 85f792e28..8f7e12cc3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -128,6 +128,7 @@ Прямой DNS Отключить Перезапись домена +По одному в строке. Строки, начинающиеся с #, считаются комментариями. Пример:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 Разрешить домены в обходных маршрутах с помощью Direct DNS. Помните о потенциальных утечках DNS Документы Правило разрешения доменов @@ -239,6 +240,12 @@ Обратный прокси Лицензия %d строк + + %d строка + %d строки + %d строк + %d строки + Тип журнала Удерживайте для настройки размера буфера. Экспорт отладочной информации diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0e7300d5c..fbaa1526b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -268,6 +268,10 @@ 配置类型 编辑配置 %d 行 + + %d 行 + + 未设置 优先 不是资源文件: 预期 .db 为扩展名, 但 %s @@ -364,6 +368,7 @@ 启用 FakeDNS 可能导致其他应用程序在代理停止后需要重新启动以重新连接到网络 域名重写 + 一行一个,# 开头代表注释。示例:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 应用 选择应用 %d 个应用 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7b33fdc4c..7a444127b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -183,6 +183,7 @@ 透明代理模式 啟用透明代理傳入伺服器 網域重寫 + 一行一個,# 開頭代表註解。範例:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 使用在路由中被略過的直連 DNS 解析網域。請注意潛在的 DNS 洩漏 啟用 DNS 路由 直連 DNS @@ -264,6 +265,10 @@ 重新載入代理服務以套用修改 請在新增客製化規則前閱讀文件,否則您可能會無法連線至網際網路。 %d 行 + + %d 行 + + 未設定 夜間模式 跟隨系統 啟用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf949e46a..a787b0c58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -113,6 +113,7 @@ May cause other applications need to be restarted to reconnect to the network after proxy stopped Domain rewrite + One per line. Lines starting with # are comments. Example:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2 Local DNS Port Enable Transproxy Inbound Transproxy Mode @@ -345,6 +346,11 @@ Make sure you have read the documentation before adding custom rules, otherwise you may not be able to connect to the Internet. %d Lines + + %d line + %d lines + + Not set Night Mode Language Follow System diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index 9d9e061d8..710ef26e7 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -239,6 +239,12 @@ app:key="enableFakeDns" app:summary="@string/fakedns_message" app:title="@string/enable_fakedns" /> + diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/DnsHostsParseTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/DnsHostsParseTest.kt new file mode 100644 index 000000000..9c690f6ea --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/DnsHostsParseTest.kt @@ -0,0 +1,117 @@ +package io.nekohasekai.sagernet.fmt + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for parseDnsHosts: every accepted address must also parse under Go's + * net/netip (sing-box decodes the predefined map into netip.Addr), because one + * bad value fails the whole config load. Malformed lines must be dropped, never + * emitted. + */ +class DnsHostsParseTest { + + @Test + fun basicEntries_parsed() { + val hosts = parseDnsHosts( + """ + example.com 1.1.1.1 + www.example.com 1.1.1.1 1.1.1.2 + """.trimIndent(), + ) + assertEquals( + mapOf("example.com" to listOf("1.1.1.1"), "www.example.com" to listOf("1.1.1.1", "1.1.1.2")), + hosts, + ) + } + + @Test + fun commentsAndBlankLines_ignored() { + val hosts = parseDnsHosts("# comment line\n\nexample.com 1.1.1.1 # trailing comment\n#example.org 2.2.2.2") + assertEquals(mapOf("example.com" to listOf("1.1.1.1")), hosts) + } + + @Test + fun duplicateDomain_mergedAndDeduplicated() { + val hosts = parseDnsHosts("example.com 1.1.1.1\nexample.com 1.1.1.2\nexample.com 1.1.1.1") + assertEquals(mapOf("example.com" to listOf("1.1.1.1", "1.1.1.2")), hosts) + } + + @Test + fun separators_tabAndNbsp_accepted() { + val hosts = parseDnsHosts("example.com\t1.1.1.1\nexample.org\u00A02.2.2.2") + assertEquals(mapOf("example.com" to listOf("1.1.1.1"), "example.org" to listOf("2.2.2.2")), hosts) + } + + @Test + fun ipv6_bracketsUnwrappedAndValidFormsAccepted() { + val hosts = parseDnsHosts( + """ + a.example [2001:db8::1] + b.example 2001:db8::2 + c.example ::1 + d.example 1:2:3:4:5:6:7:8 + e.example 2001:0db8::1 + """.trimIndent(), + ) + assertEquals(listOf("2001:db8::1"), hosts["a.example"]) + assertEquals(listOf("2001:db8::2"), hosts["b.example"]) + assertEquals(listOf("::1"), hosts["c.example"]) + assertEquals(listOf("1:2:3:4:5:6:7:8"), hosts["d.example"]) + assertEquals(listOf("2001:0db8::1"), hosts["e.example"]) + } + + @Test + fun ipv6_netipInvalidForms_rejected() { + // All of these pass the loose NGUtil regex but fail Go's netip.ParseAddr; + // any of them reaching the config would abort the sing-box config load. + val hosts = parseDnsHosts( + """ + a.example :1::2 + b.example :::1 + c.example 1:2:::3 + d.example :1:: + e.example 1:2:3:4:5:6:7:8:: + f.example ::1:2:3:4:5:6:7:8 + g.example 1::2::3 + """.trimIndent(), + ) + assertTrue(hosts.isEmpty()) + } + + @Test + fun ipv4_leadingZeros_rejected() { + val hosts = parseDnsHosts("a.example 01.2.3.4\nb.example 1.2.3.04\nc.example 1.2.3.4") + assertEquals(mapOf("c.example" to listOf("1.2.3.4")), hosts) + } + + @Test + fun nonIpTokens_droppedNotEmitted() { + val hosts = parseDnsHosts("example.com 1.1.1.1 not-an-ip\nexample.org not-an-ip") + assertEquals(mapOf("example.com" to listOf("1.1.1.1")), hosts) + } + + @Test + fun invalidDomains_rejected() { + val hosts = parseDnsHosts( + """ + 1.2.3.4 1.1.1.1 + -bad.example 1.1.1.1 + bad-.example 1.1.1.1 + bad..example 1.1.1.1 + ba*d.example 1.1.1.1 + """.trimIndent(), + ) + assertTrue(hosts.isEmpty()) + } + + @Test + fun domain_normalization() { + val hosts = parseDnsHosts("EXAMPLE.COM. 1.1.1.1\n_dmarc.example.org 2.2.2.2\nbücher.example 3.3.3.3") + assertEquals(listOf("1.1.1.1"), hosts["example.com"]) + assertEquals(listOf("2.2.2.2"), hosts["_dmarc.example.org"]) + // IDN converted to the punycode form used in real DNS queries. + assertEquals(listOf("3.3.3.3"), hosts["xn--bcher-kva.example"]) + } +}