mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 05:44:35 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user