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 2666679f..12049fd1 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -104,6 +104,23 @@ data class MhrvConfig( */ val passthroughHosts: List = emptyList(), + /** + * Opt-out for the DoH bypass. The Rust default is to bypass DoH + * traffic (chrome.cloudflare-dns.com, dns.google, etc.) directly + * instead of routing it through the Apps Script tunnel — DoH + * already encrypts queries, so the tunnel was just adding ~2 s + * 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, + + /** + * Extra hostnames added to the built-in DoH default list. Same + * matching shape as `passthroughHosts` (exact or leading-dot + * suffix). Use to cover private / enterprise DoH endpoints. + */ + val bypassDohHosts: List = emptyList(), + /** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */ val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN, @@ -186,6 +203,18 @@ data class MhrvConfig( if (passthroughHosts.isNotEmpty()) { put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } }) } + if (tunnelDoh) put("tunnel_doh", true) + // Trim/drop-empty/dedupe before serializing — symmetric with the + // read-side normalization in loadFromJson(), so a user typing + // " doh.foo " or accidentally adding a duplicate doesn't end up + // in the saved JSON. + val cleanBypassDohHosts = bypassDohHosts + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + if (cleanBypassDohHosts.isNotEmpty()) { + put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } }) + } // Phone-scoped scan defaults. We don't expose these in the UI // because a phone isn't where you'd run a full /16 scan; users @@ -277,6 +306,14 @@ object ConfigStore { if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay) 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) + val cleanBypassDohHosts = cfg.bypassDohHosts + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + if (cleanBypassDohHosts.isNotEmpty()) { + obj.put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } }) + } // Compress with DEFLATE then base64. val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8) @@ -367,6 +404,10 @@ 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), + bypassDohHosts = obj.optJSONArray("bypass_doh_hosts")?.let { arr -> + buildList { for (i in 0 until arr.length()) add(arr.optString(i)) } + }?.filter { it.isNotBlank() }.orEmpty(), connectionMode = when (obj.optString("connection_mode", "vpn_tun")) { "proxy_only" -> ConnectionMode.PROXY_ONLY else -> ConnectionMode.VPN_TUN diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 7eb6328a..c174d76d 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -257,6 +257,14 @@ struct FormState { /// users edit `disable_padding` directly when needed (Issue #391). /// Default false (padding active). disable_padding: bool, + /// Round-tripped from config.json. Not exposed in the UI form yet — + /// the bypass-DoH default is the right answer for almost everyone + /// (DoH already encrypts, the tunnel was just adding latency), so + /// this is a config-only opt-out. See config.rs `tunnel_doh`. + tunnel_doh: bool, + /// User-supplied DoH hostnames added to the built-in default list, + /// round-tripped from config.json. See config.rs `bypass_doh_hosts`. + bypass_doh_hosts: Vec, } #[derive(Clone, Debug)] @@ -341,6 +349,8 @@ fn load_form() -> (FormState, Option) { passthrough_hosts: c.passthrough_hosts.clone(), block_quic: c.block_quic, disable_padding: c.disable_padding, + tunnel_doh: c.tunnel_doh, + bypass_doh_hosts: c.bypass_doh_hosts.clone(), } } else { FormState { @@ -370,6 +380,8 @@ fn load_form() -> (FormState, Option) { passthrough_hosts: Vec::new(), block_quic: false, disable_padding: false, + tunnel_doh: false, + bypass_doh_hosts: Vec::new(), } }; (form, load_err) @@ -519,6 +531,11 @@ impl FormState { // Issue #391: disable_padding is config-only for now. // Round-trip preserves the user's choice. disable_padding: self.disable_padding, + // DoH bypass is enabled-by-default with `tunnel_doh = false`. + // Round-trip the user's choice (and any extra hostnames they + // added) so save doesn't drop them. + tunnel_doh: self.tunnel_doh, + bypass_doh_hosts: self.bypass_doh_hosts.clone(), }) } } @@ -570,6 +587,12 @@ struct ConfigWire<'a> { max_ips_to_scan: usize, scan_batch_size: usize, google_ip_validation: bool, + /// Default false (= bypass DoH). Only emitted when explicitly true + /// so unchanged configs stay clean. + #[serde(skip_serializing_if = "is_false")] + tunnel_doh: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + bypass_doh_hosts: &'a Vec, } fn is_false(b: &bool) -> bool { @@ -618,6 +641,8 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { max_ips_to_scan: c.max_ips_to_scan, scan_batch_size: c.scan_batch_size, google_ip_validation: c.google_ip_validation, + tunnel_doh: c.tunnel_doh, + bypass_doh_hosts: &c.bypass_doh_hosts, } } } diff --git a/src/config.rs b/src/config.rs index a5808266..32bd7406 100644 --- a/src/config.rs +++ b/src/config.rs @@ -205,6 +205,45 @@ pub struct Config { /// flip on your specific ISP path. #[serde(default)] pub disable_padding: bool, + + /// Opt-out for the DoH bypass. Default `false` (= bypass active): + /// CONNECTs to well-known DoH hostnames (Cloudflare, Google, Quad9, + /// AdGuard, NextDNS, OpenDNS, browser-pinned variants like + /// `chrome.cloudflare-dns.com` and `mozilla.cloudflare-dns.com`) + /// skip the Apps Script tunnel and exit via plain TCP (or + /// `upstream_socks5` if set). DoH already encrypts the queries + /// themselves, so the only privacy property the tunnel was adding + /// is hiding *the fact that you're doing DoH* from the local + /// network — a marginal gain not worth the ~2 s Apps Script + /// round-trip cost paid on every name lookup. In Full mode this + /// was the dominant DNS slowdown source. + /// + /// Set `tunnel_doh: true` to keep DoH inside the tunnel. With the + /// bypass off, browsers that find their pinned DoH host + /// unreachable already fall back to OS DNS on their own, so + /// failure modes are graceful in either direction. + /// + /// Port-gated to TCP/443 only. A private DoH on a non-standard port + /// (e.g. `doh.internal.example:8443`) won't take the bypass path — + /// list it in `passthrough_hosts` instead, which has no port gate. + #[serde(default)] + pub tunnel_doh: bool, + + /// Extra hostnames to treat as DoH endpoints in addition to the + /// built-in default list. Case-insensitive; entries match exactly + /// OR as a dot-anchored suffix unconditionally — `doh.acme.test` + /// covers both `doh.acme.test` and `tenant.doh.acme.test`. (Unlike + /// `passthrough_hosts`, no leading dot is required for suffix + /// matching: every legitimate subdomain of a DoH host is itself + /// a DoH endpoint, so the leading-dot convention would be a + /// footgun.) Use this to cover private/enterprise DoH resolvers + /// without waiting for a release. + /// + /// Inert when `tunnel_doh = true` — the bypass itself is off, so + /// the extras have nothing to feed. The proxy logs a warning at + /// startup if both are set together. + #[serde(default)] + pub bypass_doh_hosts: Vec, } fn default_fetch_ips_from_api() -> bool { false } diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 73210b5c..f9ed4bf4 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -117,6 +117,44 @@ const YOUTUBE_RELAY_HOSTS: &[&str] = &[ "youtubei.googleapis.com", ]; +/// Built-in list of DNS-over-HTTPS endpoints. CONNECTs to these (when +/// `tunnel_doh` is left at the default of `false`, i.e. bypass enabled) +/// skip the Apps Script tunnel and exit via plain TCP. Mix of the +/// browser-pinned variants Chrome/Brave/Edge/Firefox/Safari use and the +/// well-known public DoH providers users wire up by hand. Suffix +/// matching means we don't need to enumerate every tenant subdomain +/// (e.g. `*.cloudflare-dns.com` covers Workers-hosted DoH too). +/// +/// Entries are matched case-insensitively. Both exact-match (`dns.google`) +/// and dot-anchored suffix-match (a host whose suffix is `.cloudflare-dns.com` +/// or which equals `cloudflare-dns.com`) are accepted — same shape as +/// `passthrough_hosts`'s `.foo` rule. +const DEFAULT_DOH_HOSTS: &[&str] = &[ + // The base SLD covers every tenant subdomain via suffix matching; + // the browser-pinned variants below are listed for grep/discovery + // (so a user searching "chrome.cloudflare-dns.com" finds this list) + // and are technically redundant under cloudflare-dns.com. + "cloudflare-dns.com", + "chrome.cloudflare-dns.com", + "mozilla.cloudflare-dns.com", + "1dot1dot1dot1.cloudflare-dns.com", + "dns.google", + "dns.google.com", + "dns.quad9.net", + "dns11.quad9.net", + "dns.adguard-dns.com", + "unfiltered.adguard-dns.com", + "family.adguard-dns.com", + "dns.nextdns.io", + "doh.opendns.com", + "doh.cleanbrowsing.org", + "doh.dns.sb", + "dns0.eu", + "dns.alidns.com", + "doh.pub", + "dns.mullvad.net", +]; + fn matches_sni_rewrite(host: &str, youtube_via_relay: bool) -> bool { let h = host.to_ascii_lowercase(); let h = h.trim_end_matches('.'); @@ -199,6 +237,47 @@ pub struct RewriteCtx { /// callers fall back to TCP/HTTPS. See config.rs `block_quic` for /// the trade-off. Issue #213. pub block_quic: bool, + /// If true, route DoH CONNECTs around the Apps Script tunnel via + /// plain TCP. Default true via `Config::tunnel_doh = false`. See + /// `DEFAULT_DOH_HOSTS` and `matches_doh_host` for matching, and + /// config.rs `tunnel_doh` for the trade-off. + pub bypass_doh: bool, + /// User-supplied DoH hostnames added to the built-in default list. + /// Same matching semantics as `passthrough_hosts`. + pub bypass_doh_hosts: Vec, +} + +/// True if `host` matches a known DoH endpoint — either the built-in +/// `DEFAULT_DOH_HOSTS` list or a user-supplied entry in `extra`. Match +/// is case-insensitive, and entries match either exactly OR as a +/// dot-anchored suffix unconditionally (no leading-dot requirement, +/// unlike `passthrough_hosts`). The DoH list is *always* about a +/// service — every legitimate tenant subdomain of `cloudflare-dns.com` +/// or a user's private `doh.acme.test` is a DoH endpoint, so requiring +/// users to remember to write `.doh.acme.test` would be a footgun +/// without an obvious benefit. +fn host_matches_doh_entry(h: &str, entry: &str) -> bool { + let e = entry.trim().trim_end_matches('.').to_ascii_lowercase(); + let e = e.strip_prefix('.').unwrap_or(&e); + if e.is_empty() { + return false; + } + h == e || h.ends_with(&format!(".{}", e)) +} + +pub fn matches_doh_host(host: &str, extra: &[String]) -> bool { + let h = host.to_ascii_lowercase(); + let h = h.trim_end_matches('.'); + if h.is_empty() { + return false; + } + if DEFAULT_DOH_HOSTS + .iter() + .any(|s| host_matches_doh_entry(h, s)) + { + return true; + } + extra.iter().any(|s| host_matches_doh_entry(h, s)) } /// True if `host` matches any entry in the user's passthrough list. @@ -258,6 +337,20 @@ impl ProxyServer { }; let tls_connector = TlsConnector::from(Arc::new(tls_config)); + // Surface a config combo that is otherwise silently inert: extras + // listed under `bypass_doh_hosts` only take effect when the bypass + // itself is on. A user who set `tunnel_doh: true` *and* populated + // the extras list almost certainly didn't mean to disable the + // feature their custom hosts feed into. + if config.tunnel_doh && !config.bypass_doh_hosts.is_empty() { + tracing::warn!( + "config: bypass_doh_hosts has {} entries but tunnel_doh=true — \ + the bypass is off, so the extras have no effect. Set \ + tunnel_doh=false (or omit it) to use them.", + config.bypass_doh_hosts.len() + ); + } + let rewrite_ctx = Arc::new(RewriteCtx { google_ip: config.google_ip.clone(), front_domain: config.front_domain.clone(), @@ -268,6 +361,8 @@ impl ProxyServer { youtube_via_relay: config.youtube_via_relay, passthrough_hosts: config.passthrough_hosts.clone(), block_quic: config.block_quic, + bypass_doh: !config.tunnel_doh, + bypass_doh_hosts: config.bypass_doh_hosts.clone(), }); let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); @@ -1299,6 +1394,28 @@ async fn dispatch_tunnel( 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 + // what DoH already provides. Route known DoH hosts directly. + // Port-gated to 443 so a non-TLS CONNECT to e.g. `dns.google:80` + // doesn't get diverted off-tunnel by accident. + // See `DEFAULT_DOH_HOSTS` and config.rs `tunnel_doh`. + if rewrite_ctx.bypass_doh + && port == 443 + && matches_doh_host(&host, &rewrite_ctx.bypass_doh_hosts) + { + let via = rewrite_ctx.upstream_socks5.as_deref(); + tracing::info!( + "dispatch {}:{} -> raw-tcp ({}) (doh bypass)", + host, + port, + via.unwrap_or("direct") + ); + plain_tcp_passthrough(sock, &host, port, via).await; + return Ok(()); + } + // 1. Full tunnel mode: ALL traffic goes through the batch multiplexer // (Apps Script → tunnel node → real TCP). No MITM, no cert. if rewrite_ctx.mode == Mode::Full { @@ -2834,4 +2951,64 @@ mod tests { assert!(matches_passthrough("example.com", &list)); assert!(matches_passthrough("example.com.", &list)); } + + #[test] + fn doh_default_list_exact_matches() { + let extra: Vec = vec![]; + assert!(matches_doh_host("chrome.cloudflare-dns.com", &extra)); + assert!(matches_doh_host("dns.google", &extra)); + assert!(matches_doh_host("dns.quad9.net", &extra)); + assert!(matches_doh_host("doh.opendns.com", &extra)); + } + + #[test] + fn doh_default_list_case_insensitive_and_trailing_dot() { + let extra: Vec = vec![]; + assert!(matches_doh_host("DNS.GOOGLE", &extra)); + assert!(matches_doh_host("dns.google.", &extra)); + } + + #[test] + fn doh_default_list_suffix_match_for_tenant_subdomains() { + // `cloudflare-dns.com` is in the default list — Workers-hosted + // tenant DoH endpoints sit under it and should match too. + let extra: Vec = vec![]; + assert!(matches_doh_host("tenant.cloudflare-dns.com", &extra)); + // But a substring match must NOT pass: `xcloudflare-dns.com` is + // a different domain. + assert!(!matches_doh_host("xcloudflare-dns.com", &extra)); + } + + #[test] + fn doh_default_list_unrelated_hosts_do_not_match() { + let extra: Vec = vec![]; + assert!(!matches_doh_host("example.com", &extra)); + assert!(!matches_doh_host("googlevideo.com", &extra)); + assert!(!matches_doh_host("", &extra)); + } + + #[test] + fn doh_extra_list_extends_default() { + let extra = vec![".internal-doh.example".to_string(), "doh.acme.test".to_string()]; + // Defaults still match. + assert!(matches_doh_host("dns.google", &extra)); + // User additions match. + assert!(matches_doh_host("doh.acme.test", &extra)); + assert!(matches_doh_host("a.b.internal-doh.example", &extra)); + // Unrelated still doesn't match. + assert!(!matches_doh_host("example.com", &extra)); + } + + #[test] + fn doh_extra_entries_match_subdomains_without_leading_dot() { + // Asymmetry footgun guard: user adds `doh.acme.test` and expects + // `tenant.doh.acme.test` to match too — same as `dns.google` + // matching `tenant.dns.google` from the default list. Unlike + // `passthrough_hosts`, DoH extras don't require a leading dot. + let extra = vec!["doh.acme.test".to_string()]; + assert!(matches_doh_host("doh.acme.test", &extra)); + assert!(matches_doh_host("tenant.doh.acme.test", &extra)); + // But substring overlap must still be rejected. + assert!(!matches_doh_host("xdoh.acme.test", &extra)); + } }