feat: bypass Apps Script tunnel for DoH endpoints on TCP/443

This commit is contained in:
dazzling-no-more
2026-04-28 21:24:18 +04:00
parent 4c7c90a069
commit a7115cb5bd
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