Skip to content

Commit e13bca8

Browse files
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>
1 parent b45b45f commit e13bca8

4 files changed

Lines changed: 90 additions & 3 deletions

File tree

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ data class MhrvConfig(
118118
* per name lookup with no real privacy gain. Set this to true to
119119
* keep DoH inside the tunnel. See `src/config.rs` `tunnel_doh`.
120120
*/
121-
val tunnelDoh: Boolean = false,
121+
val tunnelDoh: Boolean = true,
122122

123123
/**
124124
* Extra hostnames added to the built-in DoH default list. Same
@@ -127,6 +127,13 @@ data class MhrvConfig(
127127
*/
128128
val bypassDohHosts: List<String> = emptyList(),
129129

130+
/**
131+
* When true, reject all connections to known DoH endpoints.
132+
* Browsers fall back to system DNS (tun2proxy virtual DNS — instant).
133+
* Takes priority over tunnel_doh / bypass_doh.
134+
*/
135+
val blockDoh: Boolean = true,
136+
130137
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
131138
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
132139

@@ -218,7 +225,8 @@ data class MhrvConfig(
218225
if (passthroughHosts.isNotEmpty()) {
219226
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
220227
}
221-
if (tunnelDoh) put("tunnel_doh", true)
228+
put("tunnel_doh", tunnelDoh)
229+
put("block_doh", blockDoh)
222230
if (youtubeViaRelay) put("youtube_via_relay", true)
223231
// Trim/drop-empty/dedupe before serializing — symmetric with the
224232
// read-side normalization in loadFromJson(), so a user typing
@@ -325,6 +333,7 @@ object ConfigStore {
325333
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
326334
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
327335
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
336+
if (cfg.blockDoh != defaults.blockDoh) obj.put("block_doh", cfg.blockDoh)
328337
if (cfg.youtubeViaRelay != defaults.youtubeViaRelay) obj.put("youtube_via_relay", cfg.youtubeViaRelay)
329338
val cleanBypassDohHosts = cfg.bypassDohHosts
330339
.map { it.trim() }
@@ -428,7 +437,8 @@ object ConfigStore {
428437
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
429438
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
430439
}?.filter { it.isNotBlank() }.orEmpty(),
431-
tunnelDoh = obj.optBoolean("tunnel_doh", false),
440+
tunnelDoh = obj.optBoolean("tunnel_doh", true),
441+
blockDoh = obj.optBoolean("block_doh", true),
432442
youtubeViaRelay = obj.optBoolean("youtube_via_relay", false),
433443
bypassDohHosts = obj.optJSONArray("bypass_doh_hosts")?.let { arr ->
434444
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,51 @@ private fun AdvancedSettings(
12651265
)
12661266
}
12671267

1268+
// Block DoH toggle
1269+
Row(
1270+
verticalAlignment = Alignment.CenterVertically,
1271+
modifier = Modifier.fillMaxWidth(),
1272+
) {
1273+
Column(modifier = Modifier.weight(1f)) {
1274+
Text(
1275+
"Block DoH",
1276+
style = MaterialTheme.typography.bodyMedium,
1277+
)
1278+
Text(
1279+
"Reject browser DoH — forces instant system DNS via tun2proxy. Saves ~1.5s per domain lookup.",
1280+
style = MaterialTheme.typography.bodySmall,
1281+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1282+
)
1283+
}
1284+
Switch(
1285+
checked = cfg.blockDoh,
1286+
onCheckedChange = { onChange(cfg.copy(blockDoh = it)) },
1287+
)
1288+
}
1289+
1290+
// Bypass DoH toggle
1291+
Row(
1292+
verticalAlignment = Alignment.CenterVertically,
1293+
modifier = Modifier.fillMaxWidth(),
1294+
) {
1295+
Column(modifier = Modifier.weight(1f)) {
1296+
Text(
1297+
"Bypass DoH",
1298+
style = MaterialTheme.typography.bodyMedium,
1299+
)
1300+
Text(
1301+
"Send browser DoH direct, not through tunnel. Faster DNS — queries are still encrypted.",
1302+
style = MaterialTheme.typography.bodySmall,
1303+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1304+
)
1305+
}
1306+
Switch(
1307+
checked = !cfg.tunnelDoh,
1308+
onCheckedChange = { onChange(cfg.copy(tunnelDoh = !it)) },
1309+
enabled = !cfg.blockDoh,
1310+
)
1311+
}
1312+
12681313
// Batch coalesce step slider
12691314
Column {
12701315
Text(

src/config.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,22 @@ pub struct Config {
269269
#[serde(default)]
270270
pub bypass_doh_hosts: Vec<String>,
271271

272+
/// When true, immediately reject (close) any CONNECT to a known DoH
273+
/// endpoint. Takes priority over `tunnel_doh` — the connection is
274+
/// never established in either direction. Browsers fall back to system
275+
/// DNS, which tun2proxy handles via virtual DNS (instant, no tunnel
276+
/// round-trip). This eliminates the ~1.5s per-domain DoH overhead
277+
/// that #468's `tunnel_doh: true` default introduced.
278+
///
279+
/// Background: #468 changed `tunnel_doh` from false (bypass) to true
280+
/// (tunnel) because Iranian ISPs block direct DoH endpoints. But
281+
/// tunneling DoH costs an extra ~1.5s Apps Script round-trip per DNS
282+
/// lookup, which made every page load noticeably slower. Blocking
283+
/// DoH entirely avoids both problems: no ISP-visible DoH connection,
284+
/// no tunnel round-trip — browsers use the system DNS path instead.
285+
#[serde(default)]
286+
pub block_doh: bool,
287+
272288
/// Multi-edge domain-fronting groups. Each group is a triple of
273289
/// (edge IP, front SNI, member domains): when a CONNECT to one of
274290
/// the member domains arrives, the proxy MITMs at the local CA

src/proxy_server.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ pub struct RewriteCtx {
246246
/// `matches_doh_host` for matching, and config.rs `tunnel_doh` for
247247
/// the trade-off.
248248
pub bypass_doh: bool,
249+
/// When true, immediately reject connections to known DoH hosts.
250+
/// Takes priority over bypass_doh.
251+
pub block_doh: bool,
249252
/// User-supplied DoH hostnames added to the built-in default list.
250253
/// Same matching semantics as `passthrough_hosts`.
251254
pub bypass_doh_hosts: Vec<String>,
@@ -504,6 +507,7 @@ impl ProxyServer {
504507
passthrough_hosts: config.passthrough_hosts.clone(),
505508
block_quic: config.block_quic,
506509
bypass_doh: !config.tunnel_doh,
510+
block_doh: config.block_doh,
507511
bypass_doh_hosts: config.bypass_doh_hosts.clone(),
508512
fronting_groups,
509513
});
@@ -1581,6 +1585,18 @@ async fn dispatch_tunnel(
15811585
return Ok(());
15821586
}
15831587

1588+
// 0.4. DoH block. Reject connections to known DoH endpoints so browsers
1589+
// fall back to system DNS (tun2proxy virtual DNS — instant).
1590+
// Takes priority over bypass_doh.
1591+
if rewrite_ctx.block_doh
1592+
&& port == 443
1593+
&& matches_doh_host(&host, &rewrite_ctx.bypass_doh_hosts)
1594+
{
1595+
tracing::info!("dispatch {}:{} -> blocked (block_doh)", host, port);
1596+
drop(sock);
1597+
return Ok(());
1598+
}
1599+
15841600
// 0.5. DoH bypass. DNS-over-HTTPS is the dominant per-flow DNS cost
15851601
// in Full mode (every browser name lookup costs a ~2 s Apps
15861602
// Script round-trip), and the tunnel adds no privacy beyond

0 commit comments

Comments
 (0)