Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ data class MhrvConfig(
*/
val passthroughHosts: List<String> = 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<String> = emptyList(),

/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/bin/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -341,6 +349,8 @@ fn load_form() -> (FormState, Option<String>) {
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 {
Expand Down Expand Up @@ -370,6 +380,8 @@ fn load_form() -> (FormState, Option<String>) {
passthrough_hosts: Vec::new(),
block_quic: false,
disable_padding: false,
tunnel_doh: false,
bypass_doh_hosts: Vec::new(),
}
};
(form, load_err)
Expand Down Expand Up @@ -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(),
})
}
}
Expand Down Expand Up @@ -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<String>,
}

fn is_false(b: &bool) -> bool {
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn default_fetch_ips_from_api() -> bool { false }
Expand Down
177 changes: 177 additions & 0 deletions src/proxy_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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('.');
Expand Down Expand Up @@ -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<String>,
}

/// 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.
Expand Down Expand Up @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> = 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<String> = 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<String> = 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<String> = 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));
}
}
Loading