feat: bypass Apps Script tunnel for DoH endpoints on TCP/443 (#439)

Routes browser DoH lookups (Cloudflare, Google, Quad9, AdGuard, NextDNS, OpenDNS, dns.sb, dns0.eu, AliDNS, doh.pub, Mullvad) around the Apps Script tunnel via plain TCP. By @dazzling-no-more.

DNS-over-HTTPS is already encrypted; tunneling it adds 2s UrlFetchApp roundtrip per name without privacy benefit. New `bypass_doh_hosts` config (default true) lets users opt out. Gated to TCP/443 — private DoH on :8443 should use `passthrough_hosts`.

Local verification: cargo build clean, cargo test --lib 160/160 passing (+6 new matches_doh_host tests).
This commit is contained in:
Shin (Former Aleph)
2026-04-28 21:40:05 +03:00
committed by GitHub
4 changed files with 282 additions and 0 deletions
@@ -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,
@@ -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
+25
View File
@@ -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)]
@@ -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 {
@@ -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)
@@ -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<String>,
}
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,
}
}
}
+39
View File
@@ -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 }
+177
View File
@@ -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<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.
@@ -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);
@@ -1340,6 +1435,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 {
@@ -2913,4 +3030,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));
}
}