Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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<String, List<String>> {
val hosts = linkedMapOf<String, MutableList<String>>()
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() }
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
// 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
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
val allowAccess = findPreference<Preference>(Key.ALLOW_ACCESS)!!
val appendHttpProxy = findPreference<SwitchPreference>(Key.APPEND_HTTP_PROXY)!!
val httpProxyBypass = findPreference<EditTextPreference>(Key.HTTP_PROXY_BYPASS)!!
val dnsHosts = findPreference<EditTextPreference>(Key.DNS_HOSTS)!!
val strictRoute = findPreference<SwitchPreference>(Key.STRICT_ROUTE)!!

val showDirectSpeed = findPreference<SwitchPreference>(Key.SHOW_DIRECT_SPEED)!!
Expand Down Expand Up @@ -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<EditTextPreference> { preference ->
val count = preference.text.orEmpty()
.lineSequence()
.count { it.isNotBlank() }
if (count == 0) {
preference.context.getString(R.string.not_set)
} else {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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)
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-fa/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
<string name="enable_fakedns">فعال کردن FakeDNS</string>
<string name="fakedns_message">ممکن است نیاز باشد که برنامه‌های دیگر را برای اتصال مجدد، دوباره راه‌اندازی کنید.</string>
<string name="dns_hosts">بازنویسی دامنه</string>
<string name="dns_hosts_dialog_message">در هر خط یک مورد. خطوطی که با # شروع می‌شوند نظر هستند. مثال:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2</string>
<string name="port_local_dns">پورت DNS محلی</string>
<string name="require_transproxy">فعال کردن Transproxy ورودی</string>
<string name="transproxy_mode">حالت Transproxy</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
<string name="connection_test_url">テスト用リンクを接続</string>
<string name="port_local_dns">ローカルDNSポート</string>
<string name="dns_hosts">ドメインの書き換え</string>
<string name="dns_hosts_dialog_message">1 行に 1 つ。# で始まる行はコメントです。例:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2</string>
<string name="fakedns_message">プロキシが停止した後、ネットワークに再接続するために他のアプリケーションを再起動する必要がある場合があります。</string>
<string name="enable_fakedns">FakeDNSを有効化</string>
<string name="enable_dns_routing">DNSルーティングを有効化</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ko/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
<string name="enable_fakedns">FakeDNS 활성화</string>
<string name="fakedns_message">프록시 중지 후 네트워크에 다시 연결하기 위해 다른 앱을 재시작해야 할 수 있습니다</string>
<string name="dns_hosts">도메인 재작성</string>
<string name="dns_hosts_dialog_message">한 줄에 하나씩 입력합니다. #으로 시작하면 주석입니다. 예:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2</string>
<string name="port_local_dns">로컬 DNS 포트</string>
<string name="require_transproxy">Transproxy 인바운드 활성화</string>
<string name="transproxy_mode">Transproxy 모드</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
<string name="direct_dns">Прямой DNS</string>
<string name="disable">Отключить</string>
<string name="dns_hosts">Перезапись домена</string>
<string name="dns_hosts_dialog_message">По одному в строке. Строки, начинающиеся с #, считаются комментариями. Пример:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2</string>
<string name="dns_routing_message">Разрешить домены в обходных маршрутах с помощью Direct DNS. Помните о потенциальных утечках DNS</string>
<string name="document">Документы</string>
<string name="domain_strategy">Правило разрешения доменов</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
<string name="enable_fakedns">启用 FakeDNS</string>
<string name="fakedns_message">可能导致其他应用程序在代理停止后需要重新启动以重新连接到网络</string>
<string name="dns_hosts">域名重写</string>
<string name="dns_hosts_dialog_message">一行一个,# 开头代表注释。示例:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2</string>
<string name="apps">应用</string>
<string name="select_apps">选择应用</string>
<string name="apps_message">%d 个应用</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-zh-rTW/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
<string name="transproxy_mode">透明代理模式</string>
<string name="require_transproxy">啟用透明代理傳入伺服器</string>
<string name="dns_hosts">網域重寫</string>
<string name="dns_hosts_dialog_message">一行一個,# 開頭代表註解。範例:\nexample.com 1.1.1.1\nwww.example.com 1.1.1.1 1.1.1.2</string>
<string name="dns_routing_message">使用在路由中被略過的直連 DNS 解析網域。請注意潛在的 DNS 洩漏</string>
<string name="enable_dns_routing">啟用 DNS 路由</string>
<string name="direct_dns">直連 DNS</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
<string name="fakedns_message">May cause other applications need to be restarted to reconnect to
the network after proxy stopped</string>
<string name="dns_hosts">Domain rewrite</string>
<string name="dns_hosts_dialog_message">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</string>
<string name="port_local_dns">Local DNS Port</string>
<string name="require_transproxy">Enable Transproxy Inbound</string>
<string name="transproxy_mode">Transproxy Mode</string>
Expand Down Expand Up @@ -345,6 +346,7 @@
<string name="route_warn">Make sure you have read the documentation before adding custom rules,
otherwise you may not be able to connect to the Internet.</string>
<string name="lines">%d Lines</string>
<string name="not_set">Not set</string>
Comment thread
hawkff marked this conversation as resolved.
<string name="night_mode">Night Mode</string>
<string name="language">Language</string>
<string name="follow_system">Follow System</string>
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/xml/global_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@
app:key="enableFakeDns"
app:summary="@string/fakedns_message"
app:title="@string/enable_fakedns" />
<EditTextPreference
app:defaultValue=""
app:dialogMessage="@string/dns_hosts_dialog_message"
app:icon="@drawable/ic_baseline_transform_24"
app:key="dnsHosts"
app:title="@string/dns_hosts" />
</PreferenceCategory>

<PreferenceCategory app:title="@string/inbound_settings">
Expand Down
Loading