@@ -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 ) ;
@@ -1299,6 +1394,28 @@ async fn dispatch_tunnel(
12991394 return Ok ( ( ) ) ;
13001395 }
13011396
1397+ // 0.5. DoH bypass. DNS-over-HTTPS is the dominant per-flow DNS cost
1398+ // in Full mode (every browser name lookup costs a ~2 s Apps
1399+ // Script round-trip), and the tunnel adds no privacy beyond
1400+ // what DoH already provides. Route known DoH hosts directly.
1401+ // Port-gated to 443 so a non-TLS CONNECT to e.g. `dns.google:80`
1402+ // doesn't get diverted off-tunnel by accident.
1403+ // See `DEFAULT_DOH_HOSTS` and config.rs `tunnel_doh`.
1404+ if rewrite_ctx. bypass_doh
1405+ && port == 443
1406+ && matches_doh_host ( & host, & rewrite_ctx. bypass_doh_hosts )
1407+ {
1408+ let via = rewrite_ctx. upstream_socks5 . as_deref ( ) ;
1409+ tracing:: info!(
1410+ "dispatch {}:{} -> raw-tcp ({}) (doh bypass)" ,
1411+ host,
1412+ port,
1413+ via. unwrap_or( "direct" )
1414+ ) ;
1415+ plain_tcp_passthrough ( sock, & host, port, via) . await ;
1416+ return Ok ( ( ) ) ;
1417+ }
1418+
13021419 // 1. Full tunnel mode: ALL traffic goes through the batch multiplexer
13031420 // (Apps Script → tunnel node → real TCP). No MITM, no cert.
13041421 if rewrite_ctx. mode == Mode :: Full {
@@ -2834,4 +2951,64 @@ mod tests {
28342951 assert ! ( matches_passthrough( "example.com" , & list) ) ;
28352952 assert ! ( matches_passthrough( "example.com." , & list) ) ;
28362953 }
2954+
2955+ #[ test]
2956+ fn doh_default_list_exact_matches ( ) {
2957+ let extra: Vec < String > = vec ! [ ] ;
2958+ assert ! ( matches_doh_host( "chrome.cloudflare-dns.com" , & extra) ) ;
2959+ assert ! ( matches_doh_host( "dns.google" , & extra) ) ;
2960+ assert ! ( matches_doh_host( "dns.quad9.net" , & extra) ) ;
2961+ assert ! ( matches_doh_host( "doh.opendns.com" , & extra) ) ;
2962+ }
2963+
2964+ #[ test]
2965+ fn doh_default_list_case_insensitive_and_trailing_dot ( ) {
2966+ let extra: Vec < String > = vec ! [ ] ;
2967+ assert ! ( matches_doh_host( "DNS.GOOGLE" , & extra) ) ;
2968+ assert ! ( matches_doh_host( "dns.google." , & extra) ) ;
2969+ }
2970+
2971+ #[ test]
2972+ fn doh_default_list_suffix_match_for_tenant_subdomains ( ) {
2973+ // `cloudflare-dns.com` is in the default list — Workers-hosted
2974+ // tenant DoH endpoints sit under it and should match too.
2975+ let extra: Vec < String > = vec ! [ ] ;
2976+ assert ! ( matches_doh_host( "tenant.cloudflare-dns.com" , & extra) ) ;
2977+ // But a substring match must NOT pass: `xcloudflare-dns.com` is
2978+ // a different domain.
2979+ assert ! ( !matches_doh_host( "xcloudflare-dns.com" , & extra) ) ;
2980+ }
2981+
2982+ #[ test]
2983+ fn doh_default_list_unrelated_hosts_do_not_match ( ) {
2984+ let extra: Vec < String > = vec ! [ ] ;
2985+ assert ! ( !matches_doh_host( "example.com" , & extra) ) ;
2986+ assert ! ( !matches_doh_host( "googlevideo.com" , & extra) ) ;
2987+ assert ! ( !matches_doh_host( "" , & extra) ) ;
2988+ }
2989+
2990+ #[ test]
2991+ fn doh_extra_list_extends_default ( ) {
2992+ let extra = vec ! [ ".internal-doh.example" . to_string( ) , "doh.acme.test" . to_string( ) ] ;
2993+ // Defaults still match.
2994+ assert ! ( matches_doh_host( "dns.google" , & extra) ) ;
2995+ // User additions match.
2996+ assert ! ( matches_doh_host( "doh.acme.test" , & extra) ) ;
2997+ assert ! ( matches_doh_host( "a.b.internal-doh.example" , & extra) ) ;
2998+ // Unrelated still doesn't match.
2999+ assert ! ( !matches_doh_host( "example.com" , & extra) ) ;
3000+ }
3001+
3002+ #[ test]
3003+ fn doh_extra_entries_match_subdomains_without_leading_dot ( ) {
3004+ // Asymmetry footgun guard: user adds `doh.acme.test` and expects
3005+ // `tenant.doh.acme.test` to match too — same as `dns.google`
3006+ // matching `tenant.dns.google` from the default list. Unlike
3007+ // `passthrough_hosts`, DoH extras don't require a leading dot.
3008+ let extra = vec ! [ "doh.acme.test" . to_string( ) ] ;
3009+ assert ! ( matches_doh_host( "doh.acme.test" , & extra) ) ;
3010+ assert ! ( matches_doh_host( "tenant.doh.acme.test" , & extra) ) ;
3011+ // But substring overlap must still be rejected.
3012+ assert ! ( !matches_doh_host( "xdoh.acme.test" , & extra) ) ;
3013+ }
28373014}
0 commit comments