From 96911d8609e0419108e18cbecde16d9f4c3b4533 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:49:05 -0400 Subject: [PATCH 1/3] feat(dns): add domain rewrite setting --- .../java/io/nekohasekai/sagernet/Constants.kt | 1 + .../sagernet/database/DataStore.kt | 1 + .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 69 +++++++++++++++++++ .../sagernet/ui/SettingsPreferenceFragment.kt | 40 +++++++++++ app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/global_preferences.xml | 6 ++ 12 files changed, 125 insertions(+) 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..6c40aa0a1 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.* @@ -58,6 +59,47 @@ 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 overlong IPv6 forms) and one bad value would fail the whole config +// load. Returns the normalized address, or null when the token is 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). With "::" at + // most 7 explicit groups are allowed; without it the regex already + // enforces exactly 8. + 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 +} + +// 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. +private fun parseDnsHosts(value: String): Map> { + val hosts = linkedMapOf>() + value.lineSequence().forEach { line -> + val tokens = line.split("\\s+".toRegex()) + .map { sanitizeDnsEntry(it) } + .filter { it.isNotEmpty() } + if (tokens.size < 2 || tokens.first().startsWith("#")) return@forEach + val domain = tokens.first().lowercase() + if (domain.isIpAddress()) 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 +124,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 +282,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 +1184,31 @@ 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. + // Inserted below the loopback/force-bypass/per-subscription rules added + // after it, so proxy bootstrap resolution is never hijacked by user entries. + 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..a0fbdf066 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -124,6 +124,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 +266,45 @@ 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. + dnsHosts.summaryProvider = Preference.SummaryProvider { preference -> + val count = preference.text.orEmpty() + .lineSequence() + .count { it.isNotBlank() } + if (count == 0) { + preference.context.getString(R.string.not_set) + } else { + preference.context.getString(R.string.lines, count) + } + } + dnsHosts.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val rawValue = newValue as? String + if (rawValue == null) { + reloadListener.onPreferenceChange(dnsHosts, newValue) + } else { + // 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. + val sanitized = sanitizeDnsPreferenceValue(rawValue.replace('\t', ' ')) + if (sanitized != rawValue) { + dnsHosts.text = sanitized + needReload() + false + } else { + needReload() + true + } + } + } 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..c6c34b679 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f798a29dc..e83407245 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ルーティングを有効化 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index db408b542..8d0124b28 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 모드 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 85f792e28..bc43f912d 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 Документы Правило разрешения доменов diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0e7300d5c..1760adbb4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -364,6 +364,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..df9e64ccd 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf949e46a..e82cbd95f 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,7 @@ Make sure you have read the documentation before adding custom rules, otherwise you may not be able to connect to the Internet. %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" /> + From f9293db986b7958efa264d53ab6faad501897f81 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:01:48 -0400 Subject: [PATCH 2/3] fix(dns): tighten hosts rewrite handling --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 19 ++++++++++-- .../sagernet/ui/SettingsPreferenceFragment.kt | 31 +++++++------------ app/src/main/res/values-fa/strings.xml | 5 +++ app/src/main/res/values-ja/strings.xml | 4 +++ app/src/main/res/values-ko/strings.xml | 4 +++ app/src/main/res/values-ru/strings.xml | 6 ++++ app/src/main/res/values-zh-rCN/strings.xml | 4 +++ app/src/main/res/values-zh-rTW/strings.xml | 4 +++ app/src/main/res/values/strings.xml | 4 +++ 9 files changed, 59 insertions(+), 22 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 6c40aa0a1..7ad704016 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -78,6 +78,22 @@ private fun parseHostsAddress(token: String): String? { return value } +private fun parseHostsDomain(token: String): String? { + val domain = sanitizeDnsEntry(token).lowercase().removeSuffix(".") + if (domain.isEmpty() || domain.isIpAddress() || domain.length > 253) return null + val labels = domain.split('.') + if (labels.any { it.isEmpty() || it.length > 63 }) return null + if (labels.any { label -> + label.startsWith('-') || + label.endsWith('-') || + label.any { !(it in 'a'..'z' || it in '0'..'9' || it == '-') } + } + ) { + return null + } + return domain +} + // 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 @@ -89,8 +105,7 @@ private fun parseDnsHosts(value: String): Map> { .map { sanitizeDnsEntry(it) } .filter { it.isNotEmpty() } if (tokens.size < 2 || tokens.first().startsWith("#")) return@forEach - val domain = tokens.first().lowercase() - if (domain.isIpAddress()) return@forEach + val domain = parseHostsDomain(tokens.first()) ?: return@forEach val addresses = tokens.drop(1) .takeWhile { !it.startsWith("#") } .mapNotNull { parseHostsAddress(it) } 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 a0fbdf066..9beb08cb7 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() @@ -283,27 +287,14 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { if (count == 0) { preference.context.getString(R.string.not_set) } else { - preference.context.getString(R.string.lines, count) + preference.context.resources.getQuantityString(R.plurals.dns_hosts_lines, count, count) } } dnsHosts.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val rawValue = newValue as? String - if (rawValue == null) { - reloadListener.onPreferenceChange(dnsHosts, newValue) - } else { - // 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. - val sanitized = sanitizeDnsPreferenceValue(rawValue.replace('\t', ' ')) - if (sanitized != rawValue) { - dnsHosts.text = sanitized - needReload() - false - } else { - needReload() - true - } - } + // 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 c6c34b679..259744a20 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -295,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 e83407245..bd93e884f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -277,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 8d0124b28..eb73f5513 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -271,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 bc43f912d..8f7e12cc3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -240,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 1760adbb4..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 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index df9e64ccd..7a444127b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -265,6 +265,10 @@ 重新載入代理服務以套用修改 請在新增客製化規則前閱讀文件,否則您可能會無法連線至網際網路。 %d 行 + + %d 行 + + 未設定 夜間模式 跟隨系統 啟用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e82cbd95f..a787b0c58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,6 +346,10 @@ 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 From f8d216f0acd2d631e9e7b7d44f43b05b63002de7 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:49:44 -0400 Subject: [PATCH 3/3] fix(dns): harden hosts parsing and refine entry summary - Reject IPv6 forms with empty hextet groups that pass the app regex but fail strict address parsing downstream (leading/trailing single colon, repeated or overlapping double-colon runs). - Convert internationalized domains to punycode and allow underscore labels instead of silently dropping such entries. - Treat NBSP as a token separator for pasted content. - Exclude comment lines from the settings line-count summary. - Add unit tests covering accepted and rejected hosts entries. --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 50 ++++++-- .../sagernet/ui/SettingsPreferenceFragment.kt | 6 +- .../sagernet/fmt/DnsHostsParseTest.kt | 117 ++++++++++++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/io/nekohasekai/sagernet/fmt/DnsHostsParseTest.kt 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 7ad704016..1816b170f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -53,6 +53,7 @@ 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 { @@ -61,15 +62,21 @@ private fun sanitizeDnsEntry(value: String): String { // 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 overlong IPv6 forms) and one bad value would fail the whole config -// load. Returns the normalized address, or null when the token is not usable. +// 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). With "::" at - // most 7 explicit groups are allowed; without it the regex already - // enforces exactly 8. + // 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. @@ -79,14 +86,27 @@ private fun parseHostsAddress(token: String): String? { } private fun parseHostsDomain(token: String): String? { - val domain = sanitizeDnsEntry(token).lowercase().removeSuffix(".") + 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 == '-') } + label.any { !(it in 'a'..'z' || it in '0'..'9' || it == '-' || it == '_') } } ) { return null @@ -94,14 +114,19 @@ private fun parseHostsDomain(token: String): String? { 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. -private fun parseDnsHosts(value: String): Map> { +internal fun parseDnsHosts(value: String): Map> { val hosts = linkedMapOf>() value.lineSequence().forEach { line -> - val tokens = line.split("\\s+".toRegex()) + val tokens = line.split(hostsSeparator) .map { sanitizeDnsEntry(it) } .filter { it.isNotEmpty() } if (tokens.size < 2 || tokens.first().startsWith("#")) return@forEach @@ -1205,8 +1230,13 @@ fun buildConfig(proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean // 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. + // 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 { 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 9beb08cb7..a25d041d5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -279,11 +279,13 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { 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. + // 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() - .count { it.isNotBlank() } + .map { it.trim() } + .count { it.isNotEmpty() && !it.startsWith("#") } if (count == 0) { preference.context.getString(R.string.not_set) } else { 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"]) + } +}