From 958afca776852226a67fbfa043d9a752a08e3c69 Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Tue, 5 May 2026 12:48:55 +0200 Subject: [PATCH] fix: block DoH by default + fix Android tunnel_doh config mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Opus 4.6 (1M context) --- .../java/com/therealaleph/mhrv/ConfigStore.kt | 16 +++++-- .../com/therealaleph/mhrv/ui/HomeScreen.kt | 45 +++++++++++++++++++ src/config.rs | 16 +++++++ src/proxy_server.rs | 16 +++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 9cb535b4..3c0566e1 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -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 = 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)) } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index 0f795a60..19953f62 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -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( diff --git a/src/config.rs b/src/config.rs index f611e63e..1bde7947 100644 --- a/src/config.rs +++ b/src/config.rs @@ -269,6 +269,22 @@ pub struct Config { #[serde(default)] pub bypass_doh_hosts: Vec, + /// 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 diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 81f9071a..be6f33c4 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -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, @@ -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