@@ -117,6 +117,44 @@ const YOUTUBE_RELAY_HOSTS: &[&str] = &[
117117 "youtubei.googleapis.com" ,
118118] ;
119119
120+ /// Built-in list of DNS-over-HTTPS endpoints. CONNECTs to these (when
121+ /// `tunnel_doh` is left at the default of `false`, i.e. bypass enabled)
122+ /// skip the Apps Script tunnel and exit via plain TCP. Mix of the
123+ /// browser-pinned variants Chrome/Brave/Edge/Firefox/Safari use and the
124+ /// well-known public DoH providers users wire up by hand. Suffix
125+ /// matching means we don't need to enumerate every tenant subdomain
126+ /// (e.g. `*.cloudflare-dns.com` covers Workers-hosted DoH too).
127+ ///
128+ /// Entries are matched case-insensitively. Both exact-match (`dns.google`)
129+ /// and dot-anchored suffix-match (a host whose suffix is `.cloudflare-dns.com`
130+ /// or which equals `cloudflare-dns.com`) are accepted — same shape as
131+ /// `passthrough_hosts`'s `.foo` rule.
132+ const DEFAULT_DOH_HOSTS : & [ & str ] = & [
133+ // The base SLD covers every tenant subdomain via suffix matching;
134+ // the browser-pinned variants below are listed for grep/discovery
135+ // (so a user searching "chrome.cloudflare-dns.com" finds this list)
136+ // and are technically redundant under cloudflare-dns.com.
137+ "cloudflare-dns.com" ,
138+ "chrome.cloudflare-dns.com" ,
139+ "mozilla.cloudflare-dns.com" ,
140+ "1dot1dot1dot1.cloudflare-dns.com" ,
141+ "dns.google" ,
142+ "dns.google.com" ,
143+ "dns.quad9.net" ,
144+ "dns11.quad9.net" ,
145+ "dns.adguard-dns.com" ,
146+ "unfiltered.adguard-dns.com" ,
147+ "family.adguard-dns.com" ,
148+ "dns.nextdns.io" ,
149+ "doh.opendns.com" ,
150+ "doh.cleanbrowsing.org" ,
151+ "doh.dns.sb" ,
152+ "dns0.eu" ,
153+ "dns.alidns.com" ,
154+ "doh.pub" ,
155+ "dns.mullvad.net" ,
156+ ] ;
157+
120158fn matches_sni_rewrite ( host : & str , youtube_via_relay : bool ) -> bool {
121159 let h = host. to_ascii_lowercase ( ) ;
122160 let h = h. trim_end_matches ( '.' ) ;
@@ -199,6 +237,47 @@ pub struct RewriteCtx {
199237 /// callers fall back to TCP/HTTPS. See config.rs `block_quic` for
200238 /// the trade-off. Issue #213.
201239 pub block_quic : bool ,
240+ /// If true, route DoH CONNECTs around the Apps Script tunnel via
241+ /// plain TCP. Default true via `Config::tunnel_doh = false`. See
242+ /// `DEFAULT_DOH_HOSTS` and `matches_doh_host` for matching, and
243+ /// config.rs `tunnel_doh` for the trade-off.
244+ pub bypass_doh : bool ,
245+ /// User-supplied DoH hostnames added to the built-in default list.
246+ /// Same matching semantics as `passthrough_hosts`.
247+ pub bypass_doh_hosts : Vec < String > ,
248+ }
249+
250+ /// True if `host` matches a known DoH endpoint — either the built-in
251+ /// `DEFAULT_DOH_HOSTS` list or a user-supplied entry in `extra`. Match
252+ /// is case-insensitive, and entries match either exactly OR as a
253+ /// dot-anchored suffix unconditionally (no leading-dot requirement,
254+ /// unlike `passthrough_hosts`). The DoH list is *always* about a
255+ /// service — every legitimate tenant subdomain of `cloudflare-dns.com`
256+ /// or a user's private `doh.acme.test` is a DoH endpoint, so requiring
257+ /// users to remember to write `.doh.acme.test` would be a footgun
258+ /// without an obvious benefit.
259+ fn host_matches_doh_entry ( h : & str , entry : & str ) -> bool {
260+ let e = entry. trim ( ) . trim_end_matches ( '.' ) . to_ascii_lowercase ( ) ;
261+ let e = e. strip_prefix ( '.' ) . unwrap_or ( & e) ;
262+ if e. is_empty ( ) {
263+ return false ;
264+ }
265+ h == e || h. ends_with ( & format ! ( ".{}" , e) )
266+ }
267+
268+ pub fn matches_doh_host ( host : & str , extra : & [ String ] ) -> bool {
269+ let h = host. to_ascii_lowercase ( ) ;
270+ let h = h. trim_end_matches ( '.' ) ;
271+ if h. is_empty ( ) {
272+ return false ;
273+ }
274+ if DEFAULT_DOH_HOSTS
275+ . iter ( )
276+ . any ( |s| host_matches_doh_entry ( h, s) )
277+ {
278+ return true ;
279+ }
280+ extra. iter ( ) . any ( |s| host_matches_doh_entry ( h, s) )
202281}
203282
204283/// True if `host` matches any entry in the user's passthrough list.
@@ -258,6 +337,20 @@ impl ProxyServer {
258337 } ;
259338 let tls_connector = TlsConnector :: from ( Arc :: new ( tls_config) ) ;
260339
340+ // Surface a config combo that is otherwise silently inert: extras
341+ // listed under `bypass_doh_hosts` only take effect when the bypass
342+ // itself is on. A user who set `tunnel_doh: true` *and* populated
343+ // the extras list almost certainly didn't mean to disable the
344+ // feature their custom hosts feed into.
345+ if config. tunnel_doh && !config. bypass_doh_hosts . is_empty ( ) {
346+ tracing:: warn!(
347+ "config: bypass_doh_hosts has {} entries but tunnel_doh=true — \
348+ the bypass is off, so the extras have no effect. Set \
349+ tunnel_doh=false (or omit it) to use them.",
350+ config. bypass_doh_hosts. len( )
351+ ) ;
352+ }
353+
261354 let rewrite_ctx = Arc :: new ( RewriteCtx {
262355 google_ip : config. google_ip . clone ( ) ,
263356 front_domain : config. front_domain . clone ( ) ,
@@ -268,6 +361,8 @@ impl ProxyServer {
268361 youtube_via_relay : config. youtube_via_relay ,
269362 passthrough_hosts : config. passthrough_hosts . clone ( ) ,
270363 block_quic : config. block_quic ,
364+ bypass_doh : !config. tunnel_doh ,
365+ bypass_doh_hosts : config. bypass_doh_hosts . clone ( ) ,
271366 } ) ;
272367
273368 let socks5_port = config. socks5_port . unwrap_or ( config. listen_port + 1 ) ;
@@ -1340,6 +1435,28 @@ async fn dispatch_tunnel(
13401435 return Ok ( ( ) ) ;
13411436 }
13421437
1438+ // 0.5. DoH bypass. DNS-over-HTTPS is the dominant per-flow DNS cost
1439+ // in Full mode (every browser name lookup costs a ~2 s Apps
1440+ // Script round-trip), and the tunnel adds no privacy beyond
1441+ // what DoH already provides. Route known DoH hosts directly.
1442+ // Port-gated to 443 so a non-TLS CONNECT to e.g. `dns.google:80`
1443+ // doesn't get diverted off-tunnel by accident.
1444+ // See `DEFAULT_DOH_HOSTS` and config.rs `tunnel_doh`.
1445+ if rewrite_ctx. bypass_doh
1446+ && port == 443
1447+ && matches_doh_host ( & host, & rewrite_ctx. bypass_doh_hosts )
1448+ {
1449+ let via = rewrite_ctx. upstream_socks5 . as_deref ( ) ;
1450+ tracing:: info!(
1451+ "dispatch {}:{} -> raw-tcp ({}) (doh bypass)" ,
1452+ host,
1453+ port,
1454+ via. unwrap_or( "direct" )
1455+ ) ;
1456+ plain_tcp_passthrough ( sock, & host, port, via) . await ;
1457+ return Ok ( ( ) ) ;
1458+ }
1459+
13431460 // 1. Full tunnel mode: ALL traffic goes through the batch multiplexer
13441461 // (Apps Script → tunnel node → real TCP). No MITM, no cert.
13451462 if rewrite_ctx. mode == Mode :: Full {
@@ -2913,4 +3030,64 @@ mod tests {
29133030 assert ! ( matches_passthrough( "example.com" , & list) ) ;
29143031 assert ! ( matches_passthrough( "example.com." , & list) ) ;
29153032 }
3033+
3034+ #[ test]
3035+ fn doh_default_list_exact_matches ( ) {
3036+ let extra: Vec < String > = vec ! [ ] ;
3037+ assert ! ( matches_doh_host( "chrome.cloudflare-dns.com" , & extra) ) ;
3038+ assert ! ( matches_doh_host( "dns.google" , & extra) ) ;
3039+ assert ! ( matches_doh_host( "dns.quad9.net" , & extra) ) ;
3040+ assert ! ( matches_doh_host( "doh.opendns.com" , & extra) ) ;
3041+ }
3042+
3043+ #[ test]
3044+ fn doh_default_list_case_insensitive_and_trailing_dot ( ) {
3045+ let extra: Vec < String > = vec ! [ ] ;
3046+ assert ! ( matches_doh_host( "DNS.GOOGLE" , & extra) ) ;
3047+ assert ! ( matches_doh_host( "dns.google." , & extra) ) ;
3048+ }
3049+
3050+ #[ test]
3051+ fn doh_default_list_suffix_match_for_tenant_subdomains ( ) {
3052+ // `cloudflare-dns.com` is in the default list — Workers-hosted
3053+ // tenant DoH endpoints sit under it and should match too.
3054+ let extra: Vec < String > = vec ! [ ] ;
3055+ assert ! ( matches_doh_host( "tenant.cloudflare-dns.com" , & extra) ) ;
3056+ // But a substring match must NOT pass: `xcloudflare-dns.com` is
3057+ // a different domain.
3058+ assert ! ( !matches_doh_host( "xcloudflare-dns.com" , & extra) ) ;
3059+ }
3060+
3061+ #[ test]
3062+ fn doh_default_list_unrelated_hosts_do_not_match ( ) {
3063+ let extra: Vec < String > = vec ! [ ] ;
3064+ assert ! ( !matches_doh_host( "example.com" , & extra) ) ;
3065+ assert ! ( !matches_doh_host( "googlevideo.com" , & extra) ) ;
3066+ assert ! ( !matches_doh_host( "" , & extra) ) ;
3067+ }
3068+
3069+ #[ test]
3070+ fn doh_extra_list_extends_default ( ) {
3071+ let extra = vec ! [ ".internal-doh.example" . to_string( ) , "doh.acme.test" . to_string( ) ] ;
3072+ // Defaults still match.
3073+ assert ! ( matches_doh_host( "dns.google" , & extra) ) ;
3074+ // User additions match.
3075+ assert ! ( matches_doh_host( "doh.acme.test" , & extra) ) ;
3076+ assert ! ( matches_doh_host( "a.b.internal-doh.example" , & extra) ) ;
3077+ // Unrelated still doesn't match.
3078+ assert ! ( !matches_doh_host( "example.com" , & extra) ) ;
3079+ }
3080+
3081+ #[ test]
3082+ fn doh_extra_entries_match_subdomains_without_leading_dot ( ) {
3083+ // Asymmetry footgun guard: user adds `doh.acme.test` and expects
3084+ // `tenant.doh.acme.test` to match too — same as `dns.google`
3085+ // matching `tenant.dns.google` from the default list. Unlike
3086+ // `passthrough_hosts`, DoH extras don't require a leading dot.
3087+ let extra = vec ! [ "doh.acme.test" . to_string( ) ] ;
3088+ assert ! ( matches_doh_host( "doh.acme.test" , & extra) ) ;
3089+ assert ! ( matches_doh_host( "tenant.doh.acme.test" , & extra) ) ;
3090+ // But substring overlap must still be rejected.
3091+ assert ! ( !matches_doh_host( "xdoh.acme.test" , & extra) ) ;
3092+ }
29163093}
0 commit comments