@@ -50,6 +50,46 @@ function _decoyOrError(jsonBody) {
5050 . setMimeType ( ContentService . MimeType . HTML ) ;
5151}
5252
53+ // Edge DNS cache. Plain UDP/53 queries normally traverse the full
54+ // client → GAS → tunnel-node → public resolver path, and the
55+ // trans-Atlantic round-trip dominates first-hop latency. When
56+ // ENABLE_EDGE_DNS_CACHE is true, _doTunnelBatch intercepts udp_open
57+ // ops with port=53, serves the reply from CacheService on a hit, or
58+ // does its own DoH lookup on a miss from inside Google's network.
59+ // Cache hits never reach the tunnel-node.
60+ //
61+ // Safety property: any failure (parse error, DoH unreachable,
62+ // CacheService error, refused qtype) returns null from _edgeDnsTry,
63+ // and the op falls through to the existing tunnel-node forward path.
64+ // Set false to disable and forward all DNS through the tunnel as
65+ // before.
66+ const ENABLE_EDGE_DNS_CACHE = true ;
67+
68+ // DoH endpoints tried in order on cache miss. All speak RFC 8484
69+ // over GET. Apps Script's outbound network peers well to all three.
70+ const EDGE_DNS_RESOLVERS = [
71+ "https://1.1.1.1/dns-query" ,
72+ "https://dns.google/dns-query" ,
73+ "https://dns.quad9.net/dns-query" ,
74+ ] ;
75+
76+ // CacheService bounds: 6h max TTL, 100KB per value, ~1000 keys, 250-char keys.
77+ const EDGE_DNS_MIN_TTL_S = 30 ;
78+ const EDGE_DNS_MAX_TTL_S = 21600 ; // 6h CacheService ceiling
79+ // Used for NXDOMAIN/SERVFAIL and the rare "no answer + no SOA in authority"
80+ // case. NOERROR/NODATA replies normally carry an SOA, and per RFC 2308 §5
81+ // we honor that SOA's TTL via _dnsMinTtl (the positive path).
82+ const EDGE_DNS_NEG_TTL_S = 45 ;
83+ const EDGE_DNS_CACHE_PREFIX = "edns:" ;
84+ // CacheService rejects keys longer than 250 chars. Names approaching the
85+ // 253-char DNS limit + prefix + qtype digits can exceed that, so we bail
86+ // before issuing the get/put. The op falls through to the tunnel-node.
87+ const EDGE_DNS_MAX_KEY_LEN = 240 ;
88+
89+ // qtypes we refuse to cache and pass through to the tunnel-node:
90+ // 255 = ANY (resolvers handle it more correctly than we would)
91+ const EDGE_DNS_REFUSE_QTYPES = { 255 : 1 } ;
92+
5393// ========================== Entry point ==========================
5494
5595function doPost ( e ) {
@@ -126,29 +166,102 @@ function _doTunnel(req) {
126166 . setMimeType ( ContentService . MimeType . JSON ) ;
127167}
128168
129- // Batch tunnel: forward all ops in one request to /tunnel/batch
169+ // Batch tunnel: forward all ops in one request to /tunnel/batch.
170+ // When ENABLE_EDGE_DNS_CACHE is true, udp_open/port=53 ops are served
171+ // locally where possible and only the remainder is forwarded.
130172function _doTunnelBatch ( req ) {
131- var payload = {
132- k : TUNNEL_AUTH_KEY ,
133- ops : req . ops || [ ] ,
134- } ;
173+ var ops = ( req && req . ops ) || [ ] ;
174+
175+ // Feature off: byte-identical to the pre-feature behavior.
176+ if ( ! ENABLE_EDGE_DNS_CACHE ) {
177+ return _doTunnelBatchForward ( ops ) ;
178+ }
179+
180+ var results = new Array ( ops . length ) ; // sparse: filled by edge-DNS hits
181+ var forwardOps = [ ] ;
182+ var forwardIdx = [ ] ;
183+
184+ for ( var i = 0 ; i < ops . length ; i ++ ) {
185+ var op = ops [ i ] ;
186+ if ( op && op . op === "udp_open" && op . port === 53 && op . d ) {
187+ var synth = _edgeDnsTry ( op ) ;
188+ if ( synth ) {
189+ results [ i ] = synth ;
190+ continue ;
191+ }
192+ }
193+ forwardOps . push ( op ) ;
194+ forwardIdx . push ( i ) ;
195+ }
196+
197+ // All ops served locally — no tunnel-node round-trip.
198+ if ( forwardOps . length === 0 ) {
199+ return _json ( { r : results } ) ;
200+ }
201+
202+ // Nothing was served locally — forward verbatim, no splice needed.
203+ if ( forwardOps . length === ops . length ) {
204+ return _doTunnelBatchForward ( ops ) ;
205+ }
206+
207+ // Partial: forward the un-served ops and splice results back in place.
208+ var resp = _doTunnelBatchFetch ( forwardOps ) ;
209+ if ( resp . error ) return _json ( { e : resp . error } ) ;
210+ if ( resp . r . length !== forwardOps . length ) {
211+ // Tunnel-node version skew — bail explicitly rather than silently
212+ // route TCP responses to UDP sids.
213+ return _json ( { e : "tunnel batch length mismatch" } ) ;
214+ }
215+ return _json ( { r : _spliceTunnelResults ( forwardIdx , resp . r , results ) } ) ;
216+ }
135217
218+ // Verbatim forward: no splice, response passed through unchanged.
219+ function _doTunnelBatchForward ( ops ) {
136220 var resp = UrlFetchApp . fetch ( TUNNEL_SERVER_URL + "/tunnel/batch" , {
137221 method : "post" ,
138222 contentType : "application/json" ,
139- payload : JSON . stringify ( payload ) ,
223+ payload : JSON . stringify ( { k : TUNNEL_AUTH_KEY , ops : ops } ) ,
140224 muteHttpExceptions : true ,
141225 followRedirects : true ,
142226 } ) ;
143-
144227 if ( resp . getResponseCode ( ) !== 200 ) {
145228 return _json ( { e : "tunnel batch HTTP " + resp . getResponseCode ( ) } ) ;
146229 }
147-
148230 return ContentService . createTextOutput ( resp . getContentText ( ) )
149231 . setMimeType ( ContentService . MimeType . JSON ) ;
150232}
151233
234+ // Forward + parse for the splice path. Returns { r:[...] } on success or
235+ // { error: "..." } on any failure.
236+ function _doTunnelBatchFetch ( ops ) {
237+ var resp = UrlFetchApp . fetch ( TUNNEL_SERVER_URL + "/tunnel/batch" , {
238+ method : "post" ,
239+ contentType : "application/json" ,
240+ payload : JSON . stringify ( { k : TUNNEL_AUTH_KEY , ops : ops } ) ,
241+ muteHttpExceptions : true ,
242+ followRedirects : true ,
243+ } ) ;
244+ if ( resp . getResponseCode ( ) !== 200 ) {
245+ return { error : "tunnel batch HTTP " + resp . getResponseCode ( ) } ;
246+ }
247+ try {
248+ var parsed = JSON . parse ( resp . getContentText ( ) ) ;
249+ return { r : ( parsed && parsed . r ) || [ ] } ;
250+ } catch ( err ) {
251+ return { error : "tunnel batch parse error" } ;
252+ }
253+ }
254+
255+ // Pure helper: writes forwardedResults[j] into allResults[forwardIdx[j]]
256+ // for each j. Returns the mutated allResults so callers can chain. Pure
257+ // function — testable without the GAS runtime.
258+ function _spliceTunnelResults ( forwardIdx , forwardedResults , allResults ) {
259+ for ( var j = 0 ; j < forwardIdx . length ; j ++ ) {
260+ allResults [ forwardIdx [ j ] ] = forwardedResults [ j ] ;
261+ }
262+ return allResults ;
263+ }
264+
152265// ========================== HTTP relay mode ==========================
153266
154267function _doSingle ( req ) {
@@ -247,3 +360,205 @@ function _json(obj) {
247360 ContentService . MimeType . JSON
248361 ) ;
249362}
363+
364+ // ========================== Edge DNS helpers ==========================
365+
366+ // Tries to serve a single udp_open DNS op from CacheService or DoH.
367+ // Returns a synthesized batch-result {sid, pkts, eof} on success, or null
368+ // on any failure / unsupported case so the caller can forward to the
369+ // tunnel-node. Null is the safe default — every error path returns null.
370+ function _edgeDnsTry ( op ) {
371+ try {
372+ var bytes = Utilities . base64Decode ( op . d ) ;
373+ if ( ! bytes || bytes . length < 12 ) return null ;
374+
375+ var q = _dnsParseQuestion ( bytes ) ;
376+ if ( ! q ) return null ;
377+ if ( EDGE_DNS_REFUSE_QTYPES [ q . qtype ] ) return null ;
378+
379+ var key = EDGE_DNS_CACHE_PREFIX + q . qtype + ":" + q . qname ;
380+ if ( key . length > EDGE_DNS_MAX_KEY_LEN ) return null ;
381+ var cache = CacheService . getScriptCache ( ) ;
382+
383+ var stored = null ;
384+ try { stored = cache . get ( key ) ; } catch ( _ ) { }
385+ if ( stored ) {
386+ try {
387+ var hit = Utilities . base64Decode ( stored ) ;
388+ if ( hit && hit . length >= 12 ) {
389+ // Rewrite txid to match this query (RFC 1035 §4.1.1).
390+ var rewritten = _dnsRewriteTxid ( hit , q . txid ) ;
391+ return {
392+ sid : "edns-cache" ,
393+ pkts : [ Utilities . base64Encode ( rewritten ) ] ,
394+ eof : true ,
395+ } ;
396+ }
397+ } catch ( _ ) { /* corrupt cache entry — fall through to DoH */ }
398+ }
399+
400+ for ( var i = 0 ; i < EDGE_DNS_RESOLVERS . length ; i ++ ) {
401+ var reply = _edgeDnsDoh ( EDGE_DNS_RESOLVERS [ i ] , bytes ) ;
402+ if ( ! reply ) continue ;
403+
404+ var rcode = reply [ 3 ] & 0x0F ;
405+ var ttl ;
406+ if ( rcode === 2 || rcode === 3 ) {
407+ ttl = EDGE_DNS_NEG_TTL_S ;
408+ } else {
409+ var minTtl = _dnsMinTtl ( reply ) ;
410+ ttl = ( minTtl === null ) ? EDGE_DNS_NEG_TTL_S : minTtl ;
411+ if ( ttl < EDGE_DNS_MIN_TTL_S ) ttl = EDGE_DNS_MIN_TTL_S ;
412+ if ( ttl > EDGE_DNS_MAX_TTL_S ) ttl = EDGE_DNS_MAX_TTL_S ;
413+ }
414+
415+ try {
416+ cache . put ( key , Utilities . base64Encode ( reply ) , ttl ) ;
417+ } catch ( _ ) {
418+ // >100KB value or transient quota — still return the live answer.
419+ }
420+
421+ // The DoH reply already echoes our query's txid; rewrite defensively
422+ // in case a resolver mangles it.
423+ var fixed = _dnsRewriteTxid ( reply , q . txid ) ;
424+ return {
425+ sid : "edns-doh" ,
426+ pkts : [ Utilities . base64Encode ( fixed ) ] ,
427+ eof : true ,
428+ } ;
429+ }
430+ return null ;
431+ } catch ( err ) {
432+ return null ;
433+ }
434+ }
435+
436+ // Single DoH GET against `url`. Returns the reply as a byte array, or null
437+ // on any failure (HTTP non-200, network error, malformed body).
438+ function _edgeDnsDoh ( url , queryBytes ) {
439+ try {
440+ var dns = Utilities . base64EncodeWebSafe ( queryBytes ) . replace ( / = + $ / , "" ) ;
441+ var resp = UrlFetchApp . fetch ( url + "?dns=" + dns , {
442+ method : "get" ,
443+ muteHttpExceptions : true ,
444+ followRedirects : true ,
445+ headers : { accept : "application/dns-message" } ,
446+ } ) ;
447+ if ( resp . getResponseCode ( ) !== 200 ) return null ;
448+ var body = resp . getContent ( ) ;
449+ if ( ! body || body . length < 12 ) return null ;
450+ return body ;
451+ } catch ( err ) {
452+ return null ;
453+ }
454+ }
455+
456+ // Returns { txid, qname, qtype } from a DNS wire-format query.
457+ // qname is lowercased and dot-joined (no trailing dot). Null on malformed.
458+ function _dnsParseQuestion ( bytes ) {
459+ if ( bytes . length < 12 ) return null ;
460+ var qdcount = ( ( bytes [ 4 ] & 0xFF ) << 8 ) | ( bytes [ 5 ] & 0xFF ) ;
461+ // RFC ambiguity: multi-question queries are essentially unused in
462+ // practice and would mis-key the cache (we'd cache a multi-answer reply
463+ // under only the first question). Bail and let the tunnel-node handle it.
464+ if ( qdcount !== 1 ) return null ;
465+
466+ var off = 12 ;
467+ var labels = [ ] ;
468+ var nameLen = 0 ;
469+ while ( off < bytes . length ) {
470+ var len = bytes [ off ] & 0xFF ;
471+ if ( len === 0 ) { off ++ ; break ; }
472+ if ( ( len & 0xC0 ) !== 0 ) return null ; // questions don't use compression
473+ if ( len > 63 ) return null ;
474+ off ++ ;
475+ if ( off + len > bytes . length ) return null ;
476+ var label = "" ;
477+ for ( var i = 0 ; i < len ; i ++ ) {
478+ var c = bytes [ off + i ] & 0xFF ;
479+ if ( c >= 0x41 && c <= 0x5A ) c += 0x20 ; // ASCII lowercase
480+ label += String . fromCharCode ( c ) ;
481+ }
482+ labels . push ( label ) ;
483+ off += len ;
484+ nameLen += len + 1 ;
485+ if ( nameLen > 255 ) return null ;
486+ }
487+ if ( off + 4 > bytes . length ) return null ;
488+ var qtype = ( ( bytes [ off ] & 0xFF ) << 8 ) | ( bytes [ off + 1 ] & 0xFF ) ;
489+
490+ return {
491+ txid : ( ( bytes [ 0 ] & 0xFF ) << 8 ) | ( bytes [ 1 ] & 0xFF ) ,
492+ qname : labels . join ( "." ) ,
493+ qtype : qtype ,
494+ } ;
495+ }
496+
497+ // Walks the DNS reply's answer + authority sections and returns the min RR
498+ // TTL, or null if there are no RRs (caller treats null as "use neg TTL").
499+ // Returns null on any malformed input.
500+ function _dnsMinTtl ( bytes ) {
501+ if ( bytes . length < 12 ) return null ;
502+ var qdcount = ( ( bytes [ 4 ] & 0xFF ) << 8 ) | ( bytes [ 5 ] & 0xFF ) ;
503+ var ancount = ( ( bytes [ 6 ] & 0xFF ) << 8 ) | ( bytes [ 7 ] & 0xFF ) ;
504+ var nscount = ( ( bytes [ 8 ] & 0xFF ) << 8 ) | ( bytes [ 9 ] & 0xFF ) ;
505+
506+ var off = 12 ;
507+ for ( var q = 0 ; q < qdcount ; q ++ ) {
508+ off = _dnsSkipName ( bytes , off ) ;
509+ if ( off < 0 || off + 4 > bytes . length ) return null ;
510+ off += 4 ;
511+ }
512+
513+ var min = null ;
514+ var rrTotal = ancount + nscount ;
515+ for ( var r = 0 ; r < rrTotal ; r ++ ) {
516+ off = _dnsSkipName ( bytes , off ) ;
517+ if ( off < 0 || off + 10 > bytes . length ) return null ;
518+ // 2B type, 2B class, 4B TTL, 2B rdlength
519+ var ttl = ( ( bytes [ off + 4 ] & 0xFF ) * 0x1000000 )
520+ + ( ( ( bytes [ off + 5 ] & 0xFF ) << 16 )
521+ | ( ( bytes [ off + 6 ] & 0xFF ) << 8 )
522+ | ( bytes [ off + 7 ] & 0xFF ) ) ;
523+ // RFC 2181: TTLs are 32-bit unsigned; values with the top bit set are
524+ // treated as 0. Multiplying the high byte (instead of <<24) avoids V8
525+ // sign-extension and keeps `ttl` in [0, 2^32).
526+ if ( ttl < 0 || ttl > 0x7FFFFFFF ) ttl = 0 ;
527+ if ( min === null || ttl < min ) min = ttl ;
528+ var rdlen = ( ( bytes [ off + 8 ] & 0xFF ) << 8 ) | ( bytes [ off + 9 ] & 0xFF ) ;
529+ off += 10 + rdlen ;
530+ if ( off > bytes . length ) return null ;
531+ }
532+ return min ;
533+ }
534+
535+ // Advances past a DNS name (sequence of labels or 16-bit pointer).
536+ // Returns the new offset, or -1 on malformed input.
537+ function _dnsSkipName ( bytes , off ) {
538+ while ( off < bytes . length ) {
539+ var len = bytes [ off ] & 0xFF ;
540+ if ( len === 0 ) return off + 1 ;
541+ if ( ( len & 0xC0 ) === 0xC0 ) {
542+ if ( off + 2 > bytes . length ) return - 1 ;
543+ return off + 2 ; // pointer terminates the name in-place
544+ }
545+ if ( ( len & 0xC0 ) !== 0 ) return - 1 ; // reserved label type
546+ if ( len > 63 ) return - 1 ;
547+ off += 1 + len ;
548+ }
549+ return - 1 ;
550+ }
551+
552+ // Returns a copy of `bytes` with the first 2 bytes overwritten by the
553+ // big-endian 16-bit transaction id. Coerces to signed-byte range so the
554+ // result round-trips through Utilities.base64Encode regardless of whether
555+ // the runtime exposes bytes as signed Java int8 or unsigned JS numbers.
556+ function _dnsRewriteTxid ( bytes , txid ) {
557+ var out = [ ] ;
558+ for ( var i = 0 ; i < bytes . length ; i ++ ) out . push ( bytes [ i ] ) ;
559+ var hi = ( txid >> 8 ) & 0xFF ;
560+ var lo = txid & 0xFF ;
561+ out [ 0 ] = hi > 127 ? hi - 256 : hi ;
562+ out [ 1 ] = lo > 127 ? lo - 256 : lo ;
563+ return out ;
564+ }
0 commit comments