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