Skip to content

Commit a7115cb

Browse files
feat: bypass Apps Script tunnel for DoH endpoints on TCP/443
1 parent 4c7c90a commit a7115cb

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,23 @@ data class MhrvConfig(
104104
*/
105105
val passthroughHosts: List<String> = emptyList(),
106106

107+
/**
108+
* Opt-out for the DoH bypass. The Rust default is to bypass DoH
109+
* traffic (chrome.cloudflare-dns.com, dns.google, etc.) directly
110+
* instead of routing it through the Apps Script tunnel — DoH
111+
* already encrypts queries, so the tunnel was just adding ~2 s
112+
* per name lookup with no real privacy gain. Set this to true to
113+
* keep DoH inside the tunnel. See `src/config.rs` `tunnel_doh`.
114+
*/
115+
val tunnelDoh: Boolean = false,
116+
117+
/**
118+
* Extra hostnames added to the built-in DoH default list. Same
119+
* matching shape as `passthroughHosts` (exact or leading-dot
120+
* suffix). Use to cover private / enterprise DoH endpoints.
121+
*/
122+
val bypassDohHosts: List<String> = emptyList(),
123+
107124
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
108125
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
109126

@@ -186,6 +203,18 @@ data class MhrvConfig(
186203
if (passthroughHosts.isNotEmpty()) {
187204
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
188205
}
206+
if (tunnelDoh) put("tunnel_doh", true)
207+
// Trim/drop-empty/dedupe before serializing — symmetric with the
208+
// read-side normalization in loadFromJson(), so a user typing
209+
// " doh.foo " or accidentally adding a duplicate doesn't end up
210+
// in the saved JSON.
211+
val cleanBypassDohHosts = bypassDohHosts
212+
.map { it.trim() }
213+
.filter { it.isNotEmpty() }
214+
.distinct()
215+
if (cleanBypassDohHosts.isNotEmpty()) {
216+
put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } })
217+
}
189218

190219
// Phone-scoped scan defaults. We don't expose these in the UI
191220
// because a phone isn't where you'd run a full /16 scan; users
@@ -277,6 +306,14 @@ object ConfigStore {
277306
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
278307
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
279308
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
309+
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
310+
val cleanBypassDohHosts = cfg.bypassDohHosts
311+
.map { it.trim() }
312+
.filter { it.isNotEmpty() }
313+
.distinct()
314+
if (cleanBypassDohHosts.isNotEmpty()) {
315+
obj.put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } })
316+
}
280317

281318
// Compress with DEFLATE then base64.
282319
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
@@ -367,6 +404,10 @@ object ConfigStore {
367404
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
368405
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
369406
}?.filter { it.isNotBlank() }.orEmpty(),
407+
tunnelDoh = obj.optBoolean("tunnel_doh", false),
408+
bypassDohHosts = obj.optJSONArray("bypass_doh_hosts")?.let { arr ->
409+
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
410+
}?.filter { it.isNotBlank() }.orEmpty(),
370411
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
371412
"proxy_only" -> ConnectionMode.PROXY_ONLY
372413
else -> ConnectionMode.VPN_TUN

src/bin/ui.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,14 @@ struct FormState {
257257
/// users edit `disable_padding` directly when needed (Issue #391).
258258
/// Default false (padding active).
259259
disable_padding: bool,
260+
/// Round-tripped from config.json. Not exposed in the UI form yet —
261+
/// the bypass-DoH default is the right answer for almost everyone
262+
/// (DoH already encrypts, the tunnel was just adding latency), so
263+
/// this is a config-only opt-out. See config.rs `tunnel_doh`.
264+
tunnel_doh: bool,
265+
/// User-supplied DoH hostnames added to the built-in default list,
266+
/// round-tripped from config.json. See config.rs `bypass_doh_hosts`.
267+
bypass_doh_hosts: Vec<String>,
260268
}
261269

262270
#[derive(Clone, Debug)]
@@ -341,6 +349,8 @@ fn load_form() -> (FormState, Option<String>) {
341349
passthrough_hosts: c.passthrough_hosts.clone(),
342350
block_quic: c.block_quic,
343351
disable_padding: c.disable_padding,
352+
tunnel_doh: c.tunnel_doh,
353+
bypass_doh_hosts: c.bypass_doh_hosts.clone(),
344354
}
345355
} else {
346356
FormState {
@@ -370,6 +380,8 @@ fn load_form() -> (FormState, Option<String>) {
370380
passthrough_hosts: Vec::new(),
371381
block_quic: false,
372382
disable_padding: false,
383+
tunnel_doh: false,
384+
bypass_doh_hosts: Vec::new(),
373385
}
374386
};
375387
(form, load_err)
@@ -519,6 +531,11 @@ impl FormState {
519531
// Issue #391: disable_padding is config-only for now.
520532
// Round-trip preserves the user's choice.
521533
disable_padding: self.disable_padding,
534+
// DoH bypass is enabled-by-default with `tunnel_doh = false`.
535+
// Round-trip the user's choice (and any extra hostnames they
536+
// added) so save doesn't drop them.
537+
tunnel_doh: self.tunnel_doh,
538+
bypass_doh_hosts: self.bypass_doh_hosts.clone(),
522539
})
523540
}
524541
}
@@ -570,6 +587,12 @@ struct ConfigWire<'a> {
570587
max_ips_to_scan: usize,
571588
scan_batch_size: usize,
572589
google_ip_validation: bool,
590+
/// Default false (= bypass DoH). Only emitted when explicitly true
591+
/// so unchanged configs stay clean.
592+
#[serde(skip_serializing_if = "is_false")]
593+
tunnel_doh: bool,
594+
#[serde(skip_serializing_if = "Vec::is_empty")]
595+
bypass_doh_hosts: &'a Vec<String>,
573596
}
574597

575598
fn is_false(b: &bool) -> bool {
@@ -618,6 +641,8 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
618641
max_ips_to_scan: c.max_ips_to_scan,
619642
scan_batch_size: c.scan_batch_size,
620643
google_ip_validation: c.google_ip_validation,
644+
tunnel_doh: c.tunnel_doh,
645+
bypass_doh_hosts: &c.bypass_doh_hosts,
621646
}
622647
}
623648
}

src/config.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,45 @@ pub struct Config {
205205
/// flip on your specific ISP path.
206206
#[serde(default)]
207207
pub disable_padding: bool,
208+
209+
/// Opt-out for the DoH bypass. Default `false` (= bypass active):
210+
/// CONNECTs to well-known DoH hostnames (Cloudflare, Google, Quad9,
211+
/// AdGuard, NextDNS, OpenDNS, browser-pinned variants like
212+
/// `chrome.cloudflare-dns.com` and `mozilla.cloudflare-dns.com`)
213+
/// skip the Apps Script tunnel and exit via plain TCP (or
214+
/// `upstream_socks5` if set). DoH already encrypts the queries
215+
/// themselves, so the only privacy property the tunnel was adding
216+
/// is hiding *the fact that you're doing DoH* from the local
217+
/// network — a marginal gain not worth the ~2 s Apps Script
218+
/// round-trip cost paid on every name lookup. In Full mode this
219+
/// was the dominant DNS slowdown source.
220+
///
221+
/// Set `tunnel_doh: true` to keep DoH inside the tunnel. With the
222+
/// bypass off, browsers that find their pinned DoH host
223+
/// unreachable already fall back to OS DNS on their own, so
224+
/// failure modes are graceful in either direction.
225+
///
226+
/// Port-gated to TCP/443 only. A private DoH on a non-standard port
227+
/// (e.g. `doh.internal.example:8443`) won't take the bypass path —
228+
/// list it in `passthrough_hosts` instead, which has no port gate.
229+
#[serde(default)]
230+
pub tunnel_doh: bool,
231+
232+
/// Extra hostnames to treat as DoH endpoints in addition to the
233+
/// built-in default list. Case-insensitive; entries match exactly
234+
/// OR as a dot-anchored suffix unconditionally — `doh.acme.test`
235+
/// covers both `doh.acme.test` and `tenant.doh.acme.test`. (Unlike
236+
/// `passthrough_hosts`, no leading dot is required for suffix
237+
/// matching: every legitimate subdomain of a DoH host is itself
238+
/// a DoH endpoint, so the leading-dot convention would be a
239+
/// footgun.) Use this to cover private/enterprise DoH resolvers
240+
/// without waiting for a release.
241+
///
242+
/// Inert when `tunnel_doh = true` — the bypass itself is off, so
243+
/// the extras have nothing to feed. The proxy logs a warning at
244+
/// startup if both are set together.
245+
#[serde(default)]
246+
pub bypass_doh_hosts: Vec<String>,
208247
}
209248

210249
fn default_fetch_ips_from_api() -> bool { false }

src/proxy_server.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,44 @@ const YOUTUBE_RELAY_HOSTS: &[&str] = &[
117117
"youtubei.googleapis.com",
118118
];
119119

120+
/// Built-in list of DNS-over-HTTPS endpoints. CONNECTs to these (when
121+
/// `tunnel_doh` is left at the default of `false`, i.e. bypass enabled)
122+
/// skip the Apps Script tunnel and exit via plain TCP. Mix of the
123+
/// browser-pinned variants Chrome/Brave/Edge/Firefox/Safari use and the
124+
/// well-known public DoH providers users wire up by hand. Suffix
125+
/// matching means we don't need to enumerate every tenant subdomain
126+
/// (e.g. `*.cloudflare-dns.com` covers Workers-hosted DoH too).
127+
///
128+
/// Entries are matched case-insensitively. Both exact-match (`dns.google`)
129+
/// and dot-anchored suffix-match (a host whose suffix is `.cloudflare-dns.com`
130+
/// or which equals `cloudflare-dns.com`) are accepted — same shape as
131+
/// `passthrough_hosts`'s `.foo` rule.
132+
const DEFAULT_DOH_HOSTS: &[&str] = &[
133+
// The base SLD covers every tenant subdomain via suffix matching;
134+
// the browser-pinned variants below are listed for grep/discovery
135+
// (so a user searching "chrome.cloudflare-dns.com" finds this list)
136+
// and are technically redundant under cloudflare-dns.com.
137+
"cloudflare-dns.com",
138+
"chrome.cloudflare-dns.com",
139+
"mozilla.cloudflare-dns.com",
140+
"1dot1dot1dot1.cloudflare-dns.com",
141+
"dns.google",
142+
"dns.google.com",
143+
"dns.quad9.net",
144+
"dns11.quad9.net",
145+
"dns.adguard-dns.com",
146+
"unfiltered.adguard-dns.com",
147+
"family.adguard-dns.com",
148+
"dns.nextdns.io",
149+
"doh.opendns.com",
150+
"doh.cleanbrowsing.org",
151+
"doh.dns.sb",
152+
"dns0.eu",
153+
"dns.alidns.com",
154+
"doh.pub",
155+
"dns.mullvad.net",
156+
];
157+
120158
fn matches_sni_rewrite(host: &str, youtube_via_relay: bool) -> bool {
121159
let h = host.to_ascii_lowercase();
122160
let h = h.trim_end_matches('.');
@@ -199,6 +237,47 @@ pub struct RewriteCtx {
199237
/// callers fall back to TCP/HTTPS. See config.rs `block_quic` for
200238
/// the trade-off. Issue #213.
201239
pub block_quic: bool,
240+
/// If true, route DoH CONNECTs around the Apps Script tunnel via
241+
/// plain TCP. Default true via `Config::tunnel_doh = false`. See
242+
/// `DEFAULT_DOH_HOSTS` and `matches_doh_host` for matching, and
243+
/// config.rs `tunnel_doh` for the trade-off.
244+
pub bypass_doh: bool,
245+
/// User-supplied DoH hostnames added to the built-in default list.
246+
/// Same matching semantics as `passthrough_hosts`.
247+
pub bypass_doh_hosts: Vec<String>,
248+
}
249+
250+
/// True if `host` matches a known DoH endpoint — either the built-in
251+
/// `DEFAULT_DOH_HOSTS` list or a user-supplied entry in `extra`. Match
252+
/// is case-insensitive, and entries match either exactly OR as a
253+
/// dot-anchored suffix unconditionally (no leading-dot requirement,
254+
/// unlike `passthrough_hosts`). The DoH list is *always* about a
255+
/// service — every legitimate tenant subdomain of `cloudflare-dns.com`
256+
/// or a user's private `doh.acme.test` is a DoH endpoint, so requiring
257+
/// users to remember to write `.doh.acme.test` would be a footgun
258+
/// without an obvious benefit.
259+
fn host_matches_doh_entry(h: &str, entry: &str) -> bool {
260+
let e = entry.trim().trim_end_matches('.').to_ascii_lowercase();
261+
let e = e.strip_prefix('.').unwrap_or(&e);
262+
if e.is_empty() {
263+
return false;
264+
}
265+
h == e || h.ends_with(&format!(".{}", e))
266+
}
267+
268+
pub fn matches_doh_host(host: &str, extra: &[String]) -> bool {
269+
let h = host.to_ascii_lowercase();
270+
let h = h.trim_end_matches('.');
271+
if h.is_empty() {
272+
return false;
273+
}
274+
if DEFAULT_DOH_HOSTS
275+
.iter()
276+
.any(|s| host_matches_doh_entry(h, s))
277+
{
278+
return true;
279+
}
280+
extra.iter().any(|s| host_matches_doh_entry(h, s))
202281
}
203282

204283
/// True if `host` matches any entry in the user's passthrough list.
@@ -258,6 +337,20 @@ impl ProxyServer {
258337
};
259338
let tls_connector = TlsConnector::from(Arc::new(tls_config));
260339

340+
// Surface a config combo that is otherwise silently inert: extras
341+
// listed under `bypass_doh_hosts` only take effect when the bypass
342+
// itself is on. A user who set `tunnel_doh: true` *and* populated
343+
// the extras list almost certainly didn't mean to disable the
344+
// feature their custom hosts feed into.
345+
if config.tunnel_doh && !config.bypass_doh_hosts.is_empty() {
346+
tracing::warn!(
347+
"config: bypass_doh_hosts has {} entries but tunnel_doh=true — \
348+
the bypass is off, so the extras have no effect. Set \
349+
tunnel_doh=false (or omit it) to use them.",
350+
config.bypass_doh_hosts.len()
351+
);
352+
}
353+
261354
let rewrite_ctx = Arc::new(RewriteCtx {
262355
google_ip: config.google_ip.clone(),
263356
front_domain: config.front_domain.clone(),
@@ -268,6 +361,8 @@ impl ProxyServer {
268361
youtube_via_relay: config.youtube_via_relay,
269362
passthrough_hosts: config.passthrough_hosts.clone(),
270363
block_quic: config.block_quic,
364+
bypass_doh: !config.tunnel_doh,
365+
bypass_doh_hosts: config.bypass_doh_hosts.clone(),
271366
});
272367

273368
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
@@ -1299,6 +1394,28 @@ async fn dispatch_tunnel(
12991394
return Ok(());
13001395
}
13011396

1397+
// 0.5. DoH bypass. DNS-over-HTTPS is the dominant per-flow DNS cost
1398+
// in Full mode (every browser name lookup costs a ~2 s Apps
1399+
// Script round-trip), and the tunnel adds no privacy beyond
1400+
// what DoH already provides. Route known DoH hosts directly.
1401+
// Port-gated to 443 so a non-TLS CONNECT to e.g. `dns.google:80`
1402+
// doesn't get diverted off-tunnel by accident.
1403+
// See `DEFAULT_DOH_HOSTS` and config.rs `tunnel_doh`.
1404+
if rewrite_ctx.bypass_doh
1405+
&& port == 443
1406+
&& matches_doh_host(&host, &rewrite_ctx.bypass_doh_hosts)
1407+
{
1408+
let via = rewrite_ctx.upstream_socks5.as_deref();
1409+
tracing::info!(
1410+
"dispatch {}:{} -> raw-tcp ({}) (doh bypass)",
1411+
host,
1412+
port,
1413+
via.unwrap_or("direct")
1414+
);
1415+
plain_tcp_passthrough(sock, &host, port, via).await;
1416+
return Ok(());
1417+
}
1418+
13021419
// 1. Full tunnel mode: ALL traffic goes through the batch multiplexer
13031420
// (Apps Script → tunnel node → real TCP). No MITM, no cert.
13041421
if rewrite_ctx.mode == Mode::Full {
@@ -2834,4 +2951,64 @@ mod tests {
28342951
assert!(matches_passthrough("example.com", &list));
28352952
assert!(matches_passthrough("example.com.", &list));
28362953
}
2954+
2955+
#[test]
2956+
fn doh_default_list_exact_matches() {
2957+
let extra: Vec<String> = vec![];
2958+
assert!(matches_doh_host("chrome.cloudflare-dns.com", &extra));
2959+
assert!(matches_doh_host("dns.google", &extra));
2960+
assert!(matches_doh_host("dns.quad9.net", &extra));
2961+
assert!(matches_doh_host("doh.opendns.com", &extra));
2962+
}
2963+
2964+
#[test]
2965+
fn doh_default_list_case_insensitive_and_trailing_dot() {
2966+
let extra: Vec<String> = vec![];
2967+
assert!(matches_doh_host("DNS.GOOGLE", &extra));
2968+
assert!(matches_doh_host("dns.google.", &extra));
2969+
}
2970+
2971+
#[test]
2972+
fn doh_default_list_suffix_match_for_tenant_subdomains() {
2973+
// `cloudflare-dns.com` is in the default list — Workers-hosted
2974+
// tenant DoH endpoints sit under it and should match too.
2975+
let extra: Vec<String> = vec![];
2976+
assert!(matches_doh_host("tenant.cloudflare-dns.com", &extra));
2977+
// But a substring match must NOT pass: `xcloudflare-dns.com` is
2978+
// a different domain.
2979+
assert!(!matches_doh_host("xcloudflare-dns.com", &extra));
2980+
}
2981+
2982+
#[test]
2983+
fn doh_default_list_unrelated_hosts_do_not_match() {
2984+
let extra: Vec<String> = vec![];
2985+
assert!(!matches_doh_host("example.com", &extra));
2986+
assert!(!matches_doh_host("googlevideo.com", &extra));
2987+
assert!(!matches_doh_host("", &extra));
2988+
}
2989+
2990+
#[test]
2991+
fn doh_extra_list_extends_default() {
2992+
let extra = vec![".internal-doh.example".to_string(), "doh.acme.test".to_string()];
2993+
// Defaults still match.
2994+
assert!(matches_doh_host("dns.google", &extra));
2995+
// User additions match.
2996+
assert!(matches_doh_host("doh.acme.test", &extra));
2997+
assert!(matches_doh_host("a.b.internal-doh.example", &extra));
2998+
// Unrelated still doesn't match.
2999+
assert!(!matches_doh_host("example.com", &extra));
3000+
}
3001+
3002+
#[test]
3003+
fn doh_extra_entries_match_subdomains_without_leading_dot() {
3004+
// Asymmetry footgun guard: user adds `doh.acme.test` and expects
3005+
// `tenant.doh.acme.test` to match too — same as `dns.google`
3006+
// matching `tenant.dns.google` from the default list. Unlike
3007+
// `passthrough_hosts`, DoH extras don't require a leading dot.
3008+
let extra = vec!["doh.acme.test".to_string()];
3009+
assert!(matches_doh_host("doh.acme.test", &extra));
3010+
assert!(matches_doh_host("tenant.doh.acme.test", &extra));
3011+
// But substring overlap must still be rejected.
3012+
assert!(!matches_doh_host("xdoh.acme.test", &extra));
3013+
}
28373014
}

0 commit comments

Comments
 (0)