Skip to content

Commit 7757663

Browse files
committed
Restrict DoH fallback and surface Android DoH errors
1 parent 761a319 commit 7757663

6 files changed

Lines changed: 150 additions & 82 deletions

File tree

android/app/src/main/java/io/linuxdo/accelerator/android/LinuxdoBinary.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ data class LinuxdoConfig(
1616
val dnsHosts: Map<String, String>,
1717
val proxyDomains: List<String>,
1818
) {
19+
fun shouldUseManagedDoh(host: String): Boolean = matchesProxyHost(host)
20+
1921
fun isManagedHost(host: String): Boolean {
2022
val candidate = host.lowercase(Locale.US)
2123
return findDnsHostOverride(candidate) != null || matchesProxyHost(candidate)

android/app/src/main/java/io/linuxdo/accelerator/android/LinuxdoDns.kt

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class LinuxdoDnsResolver(
6666
.dns(StaticDns(endpoints.associate { it.host to it.addresses }))
6767
.build()
6868

69-
private val preparedEndpoints = endpoints
69+
private val primaryEndpoint = endpoints.firstOrNull()
7070

7171
fun resolveManagedPayload(requestPayload: ByteArray, query: ParsedDnsQuery): ByteArray {
7272
val host = query.name.lowercase(Locale.US)
@@ -82,35 +82,30 @@ class LinuxdoDnsResolver(
8282
return DnsPacketCodec.buildResponse(query, resolution)
8383
}
8484

85-
var lastError: Exception? = null
86-
for (endpoint in preparedEndpoints) {
87-
try {
88-
if (supportsDnsMessage(endpoint.url)) {
89-
val resolution = queryDohDnsMessageRaw(endpoint, requestPayload, query)
85+
val endpoint = requirePrimaryEndpoint()
86+
try {
87+
if (supportsDnsMessage(endpoint.url)) {
88+
val resolution = queryDohDnsMessageRaw(endpoint, requestPayload, query)
89+
writeCache(host, query.type, resolution)
90+
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} raw dns-message")
91+
return DnsPacketCodec.buildResponse(query, resolution)
92+
}
93+
94+
if (isJsonFriendlyType(query.type)) {
95+
val resolution = DnsResolution(queryDohJson(endpoint, host, query.type))
96+
if (resolution.answers.isNotEmpty()) {
9097
writeCache(host, query.type, resolution)
91-
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} raw dns-message")
98+
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${resolution.answers.size}")
9299
return DnsPacketCodec.buildResponse(query, resolution)
93100
}
94-
95-
if (isJsonFriendlyType(query.type)) {
96-
val resolution = DnsResolution(queryDohJson(endpoint, host, query.type))
97-
if (resolution.answers.isNotEmpty()) {
98-
writeCache(host, query.type, resolution)
99-
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${resolution.answers.size}")
100-
return DnsPacketCodec.buildResponse(query, resolution)
101-
}
102-
lastError = IOException("empty answer from ${endpoint.rawUrl}")
103-
continue
104-
}
105-
106-
lastError = IOException("endpoint ${endpoint.rawUrl} does not support dns-message for type ${query.type}")
107-
} catch (error: Exception) {
108-
Log.w(tag, "resolver endpoint failed for $host via ${endpoint.rawUrl}: ${error.message}")
109-
lastError = error
101+
throw IOException("empty answer from ${endpoint.rawUrl}")
110102
}
111-
}
112103

113-
throw IOException("DoH lookup failed for $host type=${query.type}: ${lastError?.message ?: "no endpoint available"}")
104+
throw IOException("endpoint ${endpoint.rawUrl} does not support dns-message for type ${query.type}")
105+
} catch (error: Exception) {
106+
Log.w(tag, "resolver endpoint failed for $host via ${endpoint.rawUrl}: ${error.message}")
107+
throw wrapDohFailure(endpoint, host, query.type, error)
108+
}
114109
}
115110

116111
fun resolve(query: ParsedDnsQuery): DnsResolution {
@@ -124,36 +119,30 @@ class LinuxdoDnsResolver(
124119
return it
125120
}
126121

127-
val managedHost = config.isManagedHost(host)
122+
val managedHost = config.shouldUseManagedDoh(host)
128123
resolveLocal(host, query.type)?.let {
129124
val resolution = DnsResolution(it)
130125
writeCache(host, query.type, resolution)
131126
return resolution
132127
}
133128

134-
val endpoints = if (managedHost) {
135-
preparedEndpoints
136-
} else {
137-
preparedEndpoints.drop(1).ifEmpty { preparedEndpoints }
129+
if (!managedHost) {
130+
return DnsResolution(emptyList())
138131
}
139132

140-
var lastError: Exception? = null
141-
for (endpoint in endpoints) {
142-
try {
143-
val resolution = DnsResolution(queryDoh(endpoint, host, query.type))
144-
if (resolution.answers.isNotEmpty()) {
145-
writeCache(host, query.type, resolution)
146-
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${resolution.answers.size}")
147-
return resolution
148-
}
149-
lastError = IOException("empty answer from ${endpoint.rawUrl}")
150-
} catch (error: Exception) {
151-
Log.w(tag, "resolver endpoint failed for $host via ${endpoint.rawUrl}: ${error.message}")
152-
lastError = error
133+
val endpoint = requirePrimaryEndpoint()
134+
try {
135+
val resolution = DnsResolution(queryDoh(endpoint, host, query.type))
136+
if (resolution.answers.isNotEmpty()) {
137+
writeCache(host, query.type, resolution)
138+
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${resolution.answers.size}")
139+
return resolution
153140
}
141+
throw IOException("empty answer from ${endpoint.rawUrl}")
142+
} catch (error: Exception) {
143+
Log.w(tag, "resolver endpoint failed for $host via ${endpoint.rawUrl}: ${error.message}")
144+
throw wrapDohFailure(endpoint, host, query.type, error)
154145
}
155-
156-
throw IOException("DoH lookup failed for $host: ${lastError?.message ?: "no endpoint available"}")
157146
}
158147

159148
private fun resolveLocal(host: String, type: Int): List<DnsAnswerRecord>? {
@@ -186,13 +175,29 @@ class LinuxdoDnsResolver(
186175
}
187176

188177
private fun queryAlias(alias: String, type: Int): List<DnsAnswerRecord> {
189-
for (endpoint in preparedEndpoints) {
190-
try {
191-
return queryDoh(endpoint, alias, type)
192-
} catch (_: Exception) {
193-
}
178+
val endpoint = primaryEndpoint ?: return emptyList()
179+
return try {
180+
queryDoh(endpoint, alias, type)
181+
} catch (_: Exception) {
182+
emptyList()
194183
}
195-
return emptyList()
184+
}
185+
186+
private fun requirePrimaryEndpoint(): PreparedDohEndpoint {
187+
return primaryEndpoint ?: throw IOException("DoH 不可用,请自行更换 DoH")
188+
}
189+
190+
private fun wrapDohFailure(
191+
endpoint: PreparedDohEndpoint,
192+
host: String,
193+
type: Int,
194+
error: Exception,
195+
): IOException {
196+
val detail = error.message ?: error.javaClass.simpleName
197+
return IOException(
198+
"DoH 不可用,请自行更换 DoH。当前端点:${endpoint.rawUrl},查询:$host ${queryTypeName(type)},原因:$detail",
199+
error,
200+
)
196201
}
197202

198203
private fun queryDoh(

android/app/src/main/java/io/linuxdo/accelerator/android/LinuxdoVpnService.kt

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import java.io.IOException
2525
import java.net.Inet6Address
2626
import java.net.InetAddress
2727
import java.net.InetSocketAddress
28+
import java.util.Locale
2829
import java.util.concurrent.atomic.AtomicBoolean
2930
import kotlin.concurrent.thread
3031

@@ -33,6 +34,12 @@ class LinuxdoVpnService : VpnService() {
3334
private var vpnInterface: ParcelFileDescriptor? = null
3435
private var workerThread: Thread? = null
3536
private val running = AtomicBoolean(false)
37+
@Volatile
38+
private var preferManagedIpv6Status = false
39+
@Volatile
40+
private var activeDohEndpoint = "未配置"
41+
@Volatile
42+
private var lastDohFailureDetail: String? = null
3643

3744
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
3845
when (intent?.action) {
@@ -42,7 +49,7 @@ class LinuxdoVpnService : VpnService() {
4249
}
4350
ACTION_START -> {
4451
if (running.get()) {
45-
broadcastStatus(true, "加速中", null)
52+
broadcastStatus(true, currentRunningStatusText(), currentRunningDetail())
4653
return START_STICKY
4754
}
4855
ensureNotificationChannel()
@@ -65,7 +72,11 @@ class LinuxdoVpnService : VpnService() {
6572

6673
override fun onTaskRemoved(rootIntent: Intent?) {
6774
super.onTaskRemoved(rootIntent)
68-
broadcastStatus(running.get(), if (running.get()) "加速中" else "未启动", null)
75+
if (running.get()) {
76+
broadcastStatus(true, currentRunningStatusText(), currentRunningDetail())
77+
} else {
78+
broadcastStatus(false, "未启动", null)
79+
}
6980
}
7081

7182
override fun onDestroy() {
@@ -105,14 +116,11 @@ class LinuxdoVpnService : VpnService() {
105116

106117
vpnInterface = vpn
107118
running.set(true)
108-
val firstDoh = preparedEndpoints.firstOrNull()?.rawUrl ?: "未配置"
109-
Log.i(tag, "vpn established, first DoH=$firstDoh")
110-
val detail = if (preferManagedIpv6) {
111-
"linux.do 相关 DNS 走自定义 DoH,并保留 AAAA 返回;其他域名走系统默认 DNS。"
112-
} else {
113-
"linux.do 相关 DNS 走自定义 DoH,其他域名走系统默认 DNS。"
114-
}
115-
broadcastStatus(true, "加速中", detail)
119+
preferManagedIpv6Status = preferManagedIpv6
120+
activeDohEndpoint = preparedEndpoints.firstOrNull()?.rawUrl ?: "未配置"
121+
lastDohFailureDetail = null
122+
Log.i(tag, "vpn established, first DoH=$activeDohEndpoint")
123+
broadcastStatus(true, currentRunningStatusText(), currentRunningDetail())
116124
runDnsLoop(vpn, config, preparedEndpoints, systemDnsServers)
117125
} catch (error: Exception) {
118126
Log.e(tag, "startAcceleratorAsync failed", error)
@@ -148,9 +156,12 @@ class LinuxdoVpnService : VpnService() {
148156
val query = DnsPacketCodec.parseDnsQuery(requestPacket.payload) ?: continue
149157
val responsePayload = if (shouldUseManagedDoh(config, query)) {
150158
try {
151-
resolver.resolveManagedPayload(requestPacket.payload, query)
159+
val payload = resolver.resolveManagedPayload(requestPacket.payload, query)
160+
clearDohFailure()
161+
payload
152162
} catch (error: Exception) {
153163
Log.w(tag, "managed dns query failed for ${query.name} type=${query.type}: ${error.message}")
164+
reportDohFailure(query, error)
154165
DnsPacketCodec.buildResponse(query, DnsResolution(emptyList(), responseCode = 2))
155166
}
156167
} else {
@@ -174,7 +185,51 @@ class LinuxdoVpnService : VpnService() {
174185
}
175186

176187
private fun shouldUseManagedDoh(config: LinuxdoConfig, query: ParsedDnsQuery): Boolean {
177-
return config.isManagedHost(query.name)
188+
return config.shouldUseManagedDoh(query.name)
189+
}
190+
191+
private fun currentRunningStatusText(): String {
192+
return if (lastDohFailureDetail == null) "加速中" else "加速中(DoH 异常)"
193+
}
194+
195+
private fun currentRunningDetail(): String {
196+
return lastDohFailureDetail ?: buildHealthyRunningDetail()
197+
}
198+
199+
private fun buildHealthyRunningDetail(): String {
200+
val summary = if (preferManagedIpv6Status) {
201+
"仅 linux.do / *.linux.do 走自定义 DoH,并保留 AAAA 返回;其他域名走系统默认 DNS。"
202+
} else {
203+
"仅 linux.do / *.linux.do 走自定义 DoH;其他域名走系统默认 DNS。"
204+
}
205+
return "$summary 当前 DoH:$activeDohEndpoint"
206+
}
207+
208+
private fun clearDohFailure() {
209+
if (lastDohFailureDetail == null || !running.get()) {
210+
return
211+
}
212+
lastDohFailureDetail = null
213+
broadcastStatus(true, currentRunningStatusText(), currentRunningDetail())
214+
}
215+
216+
private fun reportDohFailure(query: ParsedDnsQuery, error: Exception) {
217+
val detail = buildString {
218+
append("DoH 查询失败:")
219+
append(query.name.lowercase(Locale.US))
220+
append(' ')
221+
append(queryTypeName(query.type))
222+
append("。当前 DoH:")
223+
append(activeDohEndpoint)
224+
append("。原因:")
225+
append(error.message ?: error.javaClass.simpleName)
226+
append("。请自行更换 DoH;其他域名仍走系统默认 DNS。")
227+
}
228+
if (detail == lastDohFailureDetail || !running.get()) {
229+
return
230+
}
231+
lastDohFailureDetail = detail
232+
broadcastStatus(true, currentRunningStatusText(), detail)
178233
}
179234

180235
private fun forwardSystemDns(payload: ByteArray, servers: List<InetAddress>): ByteArray? {
@@ -233,6 +288,7 @@ class LinuxdoVpnService : VpnService() {
233288
running.set(false)
234289
safeClose(vpnInterface)
235290
vpnInterface = null
291+
lastDohFailureDetail = null
236292
broadcastStatus(false, statusText, "Android VPN DNS 接管已关闭。")
237293
stopForeground(STOP_FOREGROUND_REMOVE)
238294
stopSelf()
@@ -344,7 +400,7 @@ class LinuxdoVpnService : VpnService() {
344400
if (actualRunning) {
345401
val status = if (savedStatus.isBlank() || savedStatus == "未启动") "加速中" else savedStatus
346402
val detail = if (savedDetail.isBlank()) {
347-
"linux.do 相关 DNS 由 Android VPN 接管,自定义 DoH 已生效"
403+
"linux.do / *.linux.do 走自定义 DoH;其他域名走系统默认 DNS"
348404
} else {
349405
savedDetail
350406
}

android/app/src/main/java/io/linuxdo/accelerator/android/MainActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class MainActivity : AppCompatActivity() {
104104
running,
105105
normalizedStatus,
106106
if (detail.isBlank()) {
107-
"配置文件和 Android 壳已就绪,点击开始加速后会申请 VPN 权限,并强制通过自定义 DoH 接管 linux.do 相关 DNS。"
107+
"配置文件和 Android 壳已就绪。启动后仅 linux.do / *.linux.do 走自定义 DoH,其他域名仍走系统默认 DNS。"
108108
} else {
109109
detail
110110
},
@@ -137,7 +137,7 @@ class MainActivity : AppCompatActivity() {
137137
}
138138

139139
private fun startVpnService() {
140-
renderState(false, "正在启动", "正在建立 Android VPN,并启用自定义 DoH DNS 接管...")
140+
renderState(false, "正在启动", "正在建立 Android VPN,并启用 linux.do / *.linux.do 的自定义 DoH 接管...")
141141
ContextCompat.startForegroundService(
142142
this,
143143
Intent(this, LinuxdoVpnService::class.java).setAction(LinuxdoVpnService.ACTION_START),

assets/defaults/linuxdo-accelerator.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ fake_sni = "www.cloudflare.com"
77

88
doh_endpoints = [
99
"https://aaa.ddd.oaifree.com/query-dns",
10-
"https://223.5.5.5/dns-query",
1110
]
1211
managed_prefer_ipv6 = true
1312
dns_hosts = {}

src/proxy.rs

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -429,8 +429,7 @@ fn build_upstream_request(
429429
}
430430
builder = builder.header(name, value);
431431
}
432-
builder = builder
433-
.header(HOST, request_host);
432+
builder = builder.header(HOST, request_host);
434433
builder
435434
.body(Full::new(body))
436435
.context("failed to build upstream request")
@@ -551,7 +550,13 @@ async fn resolve_upstream(state: &AppState, host: &str, port: u16) -> Result<Res
551550
.collect::<Vec<_>>(),
552551
ech_config: None,
553552
};
554-
write_cached_upstream(state, cache_key, upstream.clone(), FALLBACK_RESOLVE_CACHE_TTL).await;
553+
write_cached_upstream(
554+
state,
555+
cache_key,
556+
upstream.clone(),
557+
FALLBACK_RESOLVE_CACHE_TTL,
558+
)
559+
.await;
555560
return Ok(upstream);
556561
}
557562

@@ -632,7 +637,10 @@ async fn resolve_https_binding(
632637
Ok((bindings.into_iter().next().unwrap_or_default(), ttl))
633638
}
634639

635-
async fn doh_lookup_ip_addrs(state: &AppState, host: &str) -> Result<(Vec<IpAddr>, Option<Duration>)> {
640+
async fn doh_lookup_ip_addrs(
641+
state: &AppState,
642+
host: &str,
643+
) -> Result<(Vec<IpAddr>, Option<Duration>)> {
636644
let mut addrs = Vec::new();
637645

638646
let (ipv6_answers, ipv4_answers) =
@@ -692,18 +700,16 @@ async fn doh_query(state: &AppState, host: &str, record_type: &str) -> Result<Ve
692700
return Ok(cached);
693701
}
694702

695-
let mut last_error = None;
696-
for endpoint in &state.config.doh_endpoints {
697-
match doh_query_once(&state.doh_client, endpoint, host, record_type).await {
698-
Ok(answers) => {
699-
write_cached_doh_answers(state, cache_key.clone(), answers.clone()).await;
700-
return Ok(answers);
701-
}
702-
Err(error) => last_error = Some(error),
703-
}
704-
}
703+
let endpoint =
704+
state.config.doh_endpoints.first().ok_or_else(|| {
705+
anyhow::anyhow!("DoH 不可用,请在配置中自行更换 DoH:未配置 DoH 端点")
706+
})?;
705707

706-
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no DoH endpoint available")))
708+
let answers = doh_query_once(&state.doh_client, endpoint, host, record_type)
709+
.await
710+
.with_context(|| format!("DoH 不可用,请在配置中自行更换 DoH:{endpoint}"))?;
711+
write_cached_doh_answers(state, cache_key, answers.clone()).await;
712+
Ok(answers)
707713
}
708714

709715
async fn read_cached_upstream(state: &AppState, key: &ResolveCacheKey) -> Option<ResolvedUpstream> {

0 commit comments

Comments
 (0)