fix: block DoH by default + fix Android tunnel_doh config mismatch (#763)

Problem:
PR #468 changed `tunnel_doh` default to `true` (tunnel DoH through
Apps Script) to avoid ISP-blocked DoH on censored networks. But this
added ~1.5s of Apps Script round-trip per DNS lookup — every page
load got noticeably slower because Chrome's DoH connections had to
traverse the full tunnel path before the page could even start
connecting.

The Android side had a separate bug: `tunnelDoh` defaulted to
`false` but only emitted `tunnel_doh` to JSON when `true`. Since
the Rust default is `true`, omitting the field meant Rust always
tunneled DoH regardless of the Android UI setting — bypass_doh was
silently broken on Android.

Fix:
- Add `block_doh` config option: immediately reject (RST) connections
  to known DoH endpoints. Browsers fall back to system DNS, which
  tun2proxy handles via virtual DNS (instant, zero tunnel cost).
  Eliminates the DoH round-trip without exposing DoH connections to
  the ISP (unlike bypass_doh which sends DoH direct).
- Default `block_doh: true` on Android — tested on Chrome/Brave,
  falls back to virtual DNS correctly.
- Fix Android `tunnelDoh` default to `true` (matches Rust).
- Always emit `tunnel_doh` and `block_doh` explicitly in Android
  JSON serialization — no more default-mismatch bugs.
- Add Block DoH and Bypass DoH toggles in Android Advanced UI.
  Block DoH takes priority; Bypass DoH is disabled when Block is on.

Tested on Pixel 6 Pro: zero chrome.cloudflare-dns.com tunnel
sessions with block_doh=true. All DNS resolves instantly via
tun2proxy virtual DNS.

Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yyoyoian-pixel
2026-05-05 13:10:24 +02:00
committed by GitHub
parent b45b45f098
commit e13bca822f
4 changed files with 90 additions and 3 deletions
@@ -118,7 +118,7 @@ data class MhrvConfig(
* per name lookup with no real privacy gain. Set this to true to
* keep DoH inside the tunnel. See `src/config.rs` `tunnel_doh`.
*/
val tunnelDoh: Boolean = false,
val tunnelDoh: Boolean = true,
/**
* Extra hostnames added to the built-in DoH default list. Same
@@ -127,6 +127,13 @@ data class MhrvConfig(
*/
val bypassDohHosts: List<String> = emptyList(),
/**
* When true, reject all connections to known DoH endpoints.
* Browsers fall back to system DNS (tun2proxy virtual DNS — instant).
* Takes priority over tunnel_doh / bypass_doh.
*/
val blockDoh: Boolean = true,
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
@@ -218,7 +225,8 @@ data class MhrvConfig(
if (passthroughHosts.isNotEmpty()) {
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
}
if (tunnelDoh) put("tunnel_doh", true)
put("tunnel_doh", tunnelDoh)
put("block_doh", blockDoh)
if (youtubeViaRelay) put("youtube_via_relay", true)
// Trim/drop-empty/dedupe before serializing — symmetric with the
// read-side normalization in loadFromJson(), so a user typing
@@ -325,6 +333,7 @@ object ConfigStore {
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
if (cfg.blockDoh != defaults.blockDoh) obj.put("block_doh", cfg.blockDoh)
if (cfg.youtubeViaRelay != defaults.youtubeViaRelay) obj.put("youtube_via_relay", cfg.youtubeViaRelay)
val cleanBypassDohHosts = cfg.bypassDohHosts
.map { it.trim() }
@@ -428,7 +437,8 @@ object ConfigStore {
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
tunnelDoh = obj.optBoolean("tunnel_doh", false),
tunnelDoh = obj.optBoolean("tunnel_doh", true),
blockDoh = obj.optBoolean("block_doh", true),
youtubeViaRelay = obj.optBoolean("youtube_via_relay", false),
bypassDohHosts = obj.optJSONArray("bypass_doh_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
@@ -1265,6 +1265,51 @@ private fun AdvancedSettings(
)
}
// Block DoH toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Block DoH",
style = MaterialTheme.typography.bodyMedium,
)
Text(
"Reject browser DoH — forces instant system DNS via tun2proxy. Saves ~1.5s per domain lookup.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = cfg.blockDoh,
onCheckedChange = { onChange(cfg.copy(blockDoh = it)) },
)
}
// Bypass DoH toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Bypass DoH",
style = MaterialTheme.typography.bodyMedium,
)
Text(
"Send browser DoH direct, not through tunnel. Faster DNS — queries are still encrypted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = !cfg.tunnelDoh,
onCheckedChange = { onChange(cfg.copy(tunnelDoh = !it)) },
enabled = !cfg.blockDoh,
)
}
// Batch coalesce step slider
Column {
Text(
+16
View File
@@ -269,6 +269,22 @@ pub struct Config {
#[serde(default)]
pub bypass_doh_hosts: Vec<String>,
/// When true, immediately reject (close) any CONNECT to a known DoH
/// endpoint. Takes priority over `tunnel_doh` — the connection is
/// never established in either direction. Browsers fall back to system
/// DNS, which tun2proxy handles via virtual DNS (instant, no tunnel
/// round-trip). This eliminates the ~1.5s per-domain DoH overhead
/// that #468's `tunnel_doh: true` default introduced.
///
/// Background: #468 changed `tunnel_doh` from false (bypass) to true
/// (tunnel) because Iranian ISPs block direct DoH endpoints. But
/// tunneling DoH costs an extra ~1.5s Apps Script round-trip per DNS
/// lookup, which made every page load noticeably slower. Blocking
/// DoH entirely avoids both problems: no ISP-visible DoH connection,
/// no tunnel round-trip — browsers use the system DNS path instead.
#[serde(default)]
pub block_doh: bool,
/// Multi-edge domain-fronting groups. Each group is a triple of
/// (edge IP, front SNI, member domains): when a CONNECT to one of
/// the member domains arrives, the proxy MITMs at the local CA
+16
View File
@@ -246,6 +246,9 @@ pub struct RewriteCtx {
/// `matches_doh_host` for matching, and config.rs `tunnel_doh` for
/// the trade-off.
pub bypass_doh: bool,
/// When true, immediately reject connections to known DoH hosts.
/// Takes priority over bypass_doh.
pub block_doh: bool,
/// User-supplied DoH hostnames added to the built-in default list.
/// Same matching semantics as `passthrough_hosts`.
pub bypass_doh_hosts: Vec<String>,
@@ -504,6 +507,7 @@ impl ProxyServer {
passthrough_hosts: config.passthrough_hosts.clone(),
block_quic: config.block_quic,
bypass_doh: !config.tunnel_doh,
block_doh: config.block_doh,
bypass_doh_hosts: config.bypass_doh_hosts.clone(),
fronting_groups,
});
@@ -1581,6 +1585,18 @@ async fn dispatch_tunnel(
return Ok(());
}
// 0.4. DoH block. Reject connections to known DoH endpoints so browsers
// fall back to system DNS (tun2proxy virtual DNS — instant).
// Takes priority over bypass_doh.
if rewrite_ctx.block_doh
&& port == 443
&& matches_doh_host(&host, &rewrite_ctx.bypass_doh_hosts)
{
tracing::info!("dispatch {}:{} -> blocked (block_doh)", host, port);
drop(sock);
return Ok(());
}
// 0.5. DoH bypass. DNS-over-HTTPS is the dominant per-flow DNS cost
// in Full mode (every browser name lookup costs a ~2 s Apps
// Script round-trip), and the tunnel adds no privacy beyond