diff --git a/assets/apps_script/CodeFull.gs b/assets/apps_script/CodeFull.gs index c40a7dd8..f5b559cc 100644 --- a/assets/apps_script/CodeFull.gs +++ b/assets/apps_script/CodeFull.gs @@ -49,6 +49,9 @@ const SKIP_HEADERS = { // re-firing them. const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 }; +// Compiled once to avoid re-parsing per request in the relay hot path. +const URL_RE = /^https?:\/\//i; + // HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style // placeholder page — no proxy-shaped JSON, nothing distinctive enough // for a probe to fingerprint as a tunnel endpoint. @@ -72,11 +75,15 @@ function _decoyOrError(jsonBody) { // does its own DoH lookup on a miss from inside Google's network. // Cache hits never reach the tunnel-node. // -// Safety property: any failure (parse error, DoH unreachable, -// CacheService error, refused qtype) returns null from _edgeDnsTry, -// and the op falls through to the existing tunnel-node forward path. -// Set false to disable and forward all DNS through the tunnel as -// before. +// Safety property: parse errors, refused qtypes, and "every DoH resolver +// failed" return null from _edgeDnsResolve and the op falls through to +// the existing tunnel-node forward path. CacheService failures (transient +// quota, getAll exceptions, oversize keys) are softer: the per-batch +// cache lookup is skipped and no put happens, but DoH still runs from +// inside Google's network. The per-op outcome degrades to "uncached +// forward via DoH" rather than "forwarded all the way to the tunnel-node". +// Set ENABLE_EDGE_DNS_CACHE=false to disable the whole feature and route +// all DNS through the tunnel as before. const ENABLE_EDGE_DNS_CACHE = true; // DoH endpoints tried in order on cache miss. All speak RFC 8484 @@ -96,8 +103,9 @@ const EDGE_DNS_MAX_TTL_S = 21600; // 6h CacheService ceiling const EDGE_DNS_NEG_TTL_S = 45; const EDGE_DNS_CACHE_PREFIX = "edns:"; // CacheService rejects keys longer than 250 chars. Names approaching the -// 253-char DNS limit + prefix + qtype digits can exceed that, so we bail -// before issuing the get/put. The op falls through to the tunnel-node. +// 253-char DNS limit + prefix + qtype digits can exceed that, so keys +// over this length get switched to a SHA-256-hashed form (see +// _edgeDnsPrepare) rather than skipping the cache entirely. const EDGE_DNS_MAX_KEY_LEN = 240; // qtypes we refuse to cache and pass through to the tunnel-node: @@ -183,6 +191,15 @@ function _doTunnel(req) { // Batch tunnel: forward all ops in one request to /tunnel/batch. // When ENABLE_EDGE_DNS_CACHE is true, udp_open/port=53 ops are served // locally where possible and only the remainder is forwarded. +// +// Edge-DNS resolution runs in two passes so the CacheService backend +// is hit exactly once for the whole batch: +// pass 1: parse each candidate's question and collect cache keys +// one cache.getAll(keys) call serves every hit +// pass 2: resolve each candidate (cache hit → synth; miss → DoH; null +// → tunnel-node forward) +// On a 5-DNS-query batch, this collapses 5 serial cache.get round trips +// into one cache.getAll round trip. function _doTunnelBatch(req) { var ops = (req && req.ops) || []; @@ -195,12 +212,14 @@ function _doTunnelBatch(req) { var forwardOps = []; var forwardIdx = []; + // Pass 1: route non-DNS ops to forward, parse DNS candidates. + var candidates = []; // [{ i, prep }, ...] for (var i = 0; i < ops.length; i++) { var op = ops[i]; if (op && op.op === "udp_open" && op.port === 53 && op.d) { - var synth = _edgeDnsTry(op); - if (synth) { - results[i] = synth; + var prep = _edgeDnsPrepare(op); + if (prep) { + candidates.push({ i: i, prep: prep }); continue; } } @@ -208,6 +227,44 @@ function _doTunnelBatch(req) { forwardIdx.push(i); } + // One batched cache lookup for every DNS candidate. CacheService.getAll + // returns a {key: value} map populated only for hits; missing keys are + // simply absent. Any failure (transient quota, backend hiccup) returns + // an empty map so each candidate falls through to its own DoH attempt + // with no cached put either — the safe degradation path. + var cacheMap = {}; + var cache = null; + if (candidates.length > 0) { + try { + cache = CacheService.getScriptCache(); + var keys = new Array(candidates.length); + for (var c = 0; c < candidates.length; c++) { + keys[c] = candidates[c].prep.key; + } + cacheMap = cache.getAll(keys) || {}; + } catch (_) { + cacheMap = {}; + cache = null; + } + } + + // Pass 2: resolve each candidate. cacheMap doubles as the in-batch dedup + // table — a successful DoH writes its encoded reply back into cacheMap + // so a later candidate with the same qname/qtype hits without re-DoH. + // On null (cache miss + DoH all failed), append to the forward path so + // the tunnel-node still gets a chance. + for (var c = 0; c < candidates.length; c++) { + var cand = candidates[c]; + var synth = _edgeDnsResolve( + cand.prep, cacheMap[cand.prep.key] || null, cache, cacheMap); + if (synth) { + results[cand.i] = synth; + } else { + forwardOps.push(ops[cand.i]); + forwardIdx.push(cand.i); + } + } + // All ops served locally — no tunnel-node round-trip. if (forwardOps.length === 0) { return _json({ r: results }); @@ -279,7 +336,7 @@ function _spliceTunnelResults(forwardIdx, forwardedResults, allResults) { // ========================== HTTP relay mode ========================== function _doSingle(req) { - if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { + if (!req.u || typeof req.u !== "string" || !URL_RE.test(req.u)) { return _json({ e: "bad url" }); } var opts = _buildOpts(req); @@ -302,7 +359,7 @@ function _doBatch(items) { errorMap[i] = "bad item"; continue; } - if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) { + if (!item.u || typeof item.u !== "string" || !URL_RE.test(item.u)) { errorMap[i] = "bad url"; continue; } @@ -403,12 +460,20 @@ function _buildOpts(req) { return opts; } +// Lazy module-level cache of the runtime feature check; reset between GAS +// executions but reused across all responses inside a single execution +// (batches of 50+ make this matter). +var _hasGetAllHeaders = null; + function _respHeaders(resp) { - try { - if (typeof resp.getAllHeaders === "function") { + if (_hasGetAllHeaders === null) { + _hasGetAllHeaders = (typeof resp.getAllHeaders === "function"); + } + if (_hasGetAllHeaders) { + try { return resp.getAllHeaders(); - } - } catch (err) {} + } catch (err) {} + } return resp.getHeaders(); } @@ -433,31 +498,54 @@ function _json(obj) { // ========================== Edge DNS helpers ========================== -// Tries to serve a single udp_open DNS op from CacheService or DoH. -// Returns a synthesized batch-result {sid, pkts, eof} on success, or null -// on any failure / unsupported case so the caller can forward to the -// tunnel-node. Null is the safe default — every error path returns null. -function _edgeDnsTry(op) { +// Phase-1 helper: parses a udp_open op into the data needed for both the +// batched cache lookup and the eventual resolve. Returns {bytes, q, key} +// on success, or null for unparseable/refused ops so the caller can route +// them to the tunnel-node forward path. +// +// Long qnames that would exceed CacheService's 250-char key limit fall back +// to a SHA-256-hashed key under a separate `edns:h:` namespace. The +// 256-bit digest makes accidental collisions astronomically unlikely, and +// the distinct namespace prevents short-name keys from colliding with +// hashed long-name keys. +function _edgeDnsPrepare(op) { try { var bytes = Utilities.base64Decode(op.d); if (!bytes || bytes.length < 12) return null; - var q = _dnsParseQuestion(bytes); if (!q) return null; if (EDGE_DNS_REFUSE_QTYPES[q.qtype]) return null; - var key = EDGE_DNS_CACHE_PREFIX + q.qtype + ":" + q.qname; - if (key.length > EDGE_DNS_MAX_KEY_LEN) return null; - var cache = CacheService.getScriptCache(); + if (key.length > EDGE_DNS_MAX_KEY_LEN) { + key = EDGE_DNS_CACHE_PREFIX + "h:" + q.qtype + ":" + _sha256Hex(q.qname); + } + return { bytes: bytes, q: q, key: key }; + } catch (_) { + return null; + } +} - var stored = null; - try { stored = cache.get(key); } catch (_) {} - if (stored) { +// Phase-2 helper: given a prepared op and an optional pre-fetched cache +// value, returns a synthesized batch-result {sid, pkts, eof} on success, +// or null on any failure so the caller can forward to the tunnel-node. +// +// `cache` is the CacheService handle reused across the batch (or null +// if CacheService is unavailable, in which case DoH still runs +// but no put). +// `localMap` is an optional in-batch lookup table (typically the same +// object returned by cache.getAll). When DoH succeeds, the +// encoded reply is written back to localMap[prep.key] so that +// a later candidate in the same batch with the same qname/qtype +// hits without a second DoH round-trip. +function _edgeDnsResolve(prep, cachedReplyB64, cache, localMap) { + try { + if (cachedReplyB64) { try { - var hit = Utilities.base64Decode(stored); + var hit = Utilities.base64Decode(cachedReplyB64); if (hit && hit.length >= 12) { - // Rewrite txid to match this query (RFC 1035 §4.1.1). - var rewritten = _dnsRewriteTxid(hit, q.txid); + // Rewrite txid to match this query (RFC 1035 §4.1.1). Returns a + // copy so the cached bytes themselves are never mutated. + var rewritten = _dnsRewriteTxid(hit, prep.q.txid); return { sid: "edns-cache", pkts: [Utilities.base64Encode(rewritten)], @@ -468,7 +556,7 @@ function _edgeDnsTry(op) { } for (var i = 0; i < EDGE_DNS_RESOLVERS.length; i++) { - var reply = _edgeDnsDoh(EDGE_DNS_RESOLVERS[i], bytes); + var reply = _edgeDnsDoh(EDGE_DNS_RESOLVERS[i], prep.bytes); if (!reply) continue; var rcode = reply[3] & 0x0F; @@ -482,15 +570,24 @@ function _edgeDnsTry(op) { if (ttl > EDGE_DNS_MAX_TTL_S) ttl = EDGE_DNS_MAX_TTL_S; } - try { - cache.put(key, Utilities.base64Encode(reply), ttl); - } catch (_) { - // >100KB value or transient quota — still return the live answer. + // Encode once and reuse for both the persistent cache and the + // in-batch dedup map. The reply bytes carry the resolver-echoed + // txid; any future hit rewrites it to that request's txid. + var encoded = (cache || localMap) ? Utilities.base64Encode(reply) : null; + if (cache) { + try { + cache.put(prep.key, encoded, ttl); + } catch (_) { + // >100KB value or transient quota — still return the live answer. + } + } + if (localMap) { + localMap[prep.key] = encoded; } // The DoH reply already echoes our query's txid; rewrite defensively // in case a resolver mangles it. - var fixed = _dnsRewriteTxid(reply, q.txid); + var fixed = _dnsRewriteTxid(reply, prep.q.txid); return { sid: "edns-doh", pkts: [Utilities.base64Encode(fixed)], @@ -503,6 +600,22 @@ function _edgeDnsTry(op) { } } +// Hex-encodes the SHA-256 of a UTF-8 string. Used to keep long-qname cache +// keys under CacheService's 250-char limit. 64 hex chars is well below the +// cap and survives any future bumps to EDGE_DNS_MAX_KEY_LEN. SHA-256 over +// MD5 here is just future-proofing — the hash isn't security-sensitive +// (cache namespace only), but SHA-256 avoids any "why MD5?" discussion. +function _sha256Hex(s) { + var d = Utilities.computeDigest( + Utilities.DigestAlgorithm.SHA_256, s, Utilities.Charset.UTF_8); + var hex = ""; + for (var i = 0; i < d.length; i++) { + var b = d[i] & 0xFF; + hex += (b < 16 ? "0" : "") + b.toString(16); + } + return hex; +} + // Single DoH GET against `url`. Returns the reply as a byte array, or null // on any failure (HTTP non-200, network error, malformed body). function _edgeDnsDoh(url, queryBytes) { @@ -623,6 +736,12 @@ function _dnsSkipName(bytes, off) { // big-endian 16-bit transaction id. Coerces to signed-byte range so the // result round-trips through Utilities.base64Encode regardless of whether // the runtime exposes bytes as signed Java int8 or unsigned JS numbers. +// +// Always copies — the cache-safety invariant (callers can hand in a buffer +// they may reuse, e.g. a CacheService string round-tripped through decode) +// is enforced here rather than via per-call-site reasoning. The copy is +// cheap (~100 bytes for a typical DNS reply) compared to the surrounding +// base64 encode/decode work. function _dnsRewriteTxid(bytes, txid) { var out = []; for (var i = 0; i < bytes.length; i++) out.push(bytes[i]); diff --git a/assets/apps_script/tests/edge_dns_batch_test.js b/assets/apps_script/tests/edge_dns_batch_test.js new file mode 100644 index 00000000..66e8b878 --- /dev/null +++ b/assets/apps_script/tests/edge_dns_batch_test.js @@ -0,0 +1,476 @@ +// Mocked-runtime tests for the batch DNS path in CodeFull.gs. +// +// Run from repo root: node assets/apps_script/tests/edge_dns_batch_test.js +// +// Complements edge_dns_test.js (pure helpers) by exercising the parts of +// the file that depend on the GAS runtime: _edgeDnsPrepare, _edgeDnsResolve, +// _doTunnelBatch, and the long-qname hash path. Mocks Utilities, +// CacheService, UrlFetchApp, and ContentService just enough that the +// extracted code runs unmodified. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const SRC = path.join(__dirname, '..', 'CodeFull.gs'); +const src = fs.readFileSync(SRC, 'utf8'); + +// =============== Source extraction =============== + +const FUNC_NAMES = [ + '_dnsSkipName', '_dnsParseQuestion', '_dnsMinTtl', '_dnsRewriteTxid', + '_sha256Hex', '_edgeDnsPrepare', '_edgeDnsResolve', '_edgeDnsDoh', + '_doTunnelBatch', '_doTunnelBatchForward', '_doTunnelBatchFetch', + '_spliceTunnelResults', '_json', +]; +const CONST_NAMES = [ + 'ENABLE_EDGE_DNS_CACHE', 'EDGE_DNS_RESOLVERS', 'EDGE_DNS_MIN_TTL_S', + 'EDGE_DNS_MAX_TTL_S', 'EDGE_DNS_NEG_TTL_S', 'EDGE_DNS_CACHE_PREFIX', + 'EDGE_DNS_MAX_KEY_LEN', 'EDGE_DNS_REFUSE_QTYPES', + 'TUNNEL_SERVER_URL', 'TUNNEL_AUTH_KEY', +]; + +let bundle = ''; +for (const name of CONST_NAMES) { + // Match through the first ";" that ends the declaration. Allow an + // optional trailing same-line comment ("const X = Y; // note") before + // the newline; otherwise the lazy quantifier would skip past and swallow + // the next const, double-declaring it. + const re = new RegExp(`const ${name}\\s*=[\\s\\S]*?;[^\\n]*\\n`); + const m = src.match(re); + if (!m) throw new Error('const not found in CodeFull.gs: ' + name); + bundle += m[0] + '\n'; +} +for (const name of FUNC_NAMES) { + const re = new RegExp(`function ${name}\\b[\\s\\S]*?\\n\\}\\n`); + const m = src.match(re); + if (!m) throw new Error('helper not found in CodeFull.gs: ' + name); + bundle += m[0] + '\n'; +} +bundle += `return { ${FUNC_NAMES.concat(CONST_NAMES).join(', ')} };`; + +function buildContext(deps) { + // eslint-disable-next-line no-new-func + const fn = new Function( + 'Utilities', 'CacheService', 'UrlFetchApp', 'ContentService', bundle); + return fn(deps.Utilities, deps.CacheService, deps.UrlFetchApp, deps.ContentService); +} + +// =============== Mocks =============== + +function bytesArr(buf) { + const arr = []; + for (let i = 0; i < buf.length; i++) arr.push(buf[i]); + return arr; +} + +function makeUtilities() { + return { + base64Decode: (s) => bytesArr(Buffer.from(s, 'base64')), + base64Encode: (b) => Buffer.from(b).toString('base64'), + base64EncodeWebSafe: (b) => + Buffer.from(b).toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_'), + computeDigest: (algo, s) => { + const h = crypto.createHash(algo); + h.update(s, 'utf8'); + return bytesArr(h.digest()); + }, + DigestAlgorithm: { MD5: 'md5', SHA_256: 'sha256' }, + Charset: { UTF_8: 'utf8' }, + }; +} + +function makeCache(opts) { + opts = opts || {}; + const store = Object.assign({}, opts.seed || {}); + let getAllCalls = 0; + const putHistory = []; + return { + handle: { + getAll: function (keys) { + getAllCalls++; + if (opts.throwOnGetAll) throw new Error('cache backend hiccup'); + const out = {}; + for (let i = 0; i < keys.length; i++) { + if (keys[i] in store) out[keys[i]] = store[keys[i]]; + } + return out; + }, + put: function (k, v, ttl) { + putHistory.push({ k: k, v: v, ttl: ttl }); + store[k] = v; + }, + }, + getAllCalls: () => getAllCalls, + putHistory: () => putHistory, + }; +} + +function makeCacheService(cacheStub) { + return { getScriptCache: () => cacheStub.handle }; +} + +function makeContentService() { + return { + createTextOutput: (s) => ({ + _text: s, + _mime: null, + setMimeType: function (m) { this._mime = m; return this; }, + }), + MimeType: { JSON: 'json', HTML: 'html' }, + }; +} + +function makeUrlFetchApp(handler) { + const calls = []; + return { + handle: { + fetch: (url, opts) => { + calls.push({ url: url, opts: opts }); + return handler(url, opts); + }, + }, + calls: () => calls, + }; +} + +// =============== DNS wire builders =============== + +function buildQuery(txid, qname, qtype) { + const labels = qname.split('.').filter((s) => s.length > 0); + const parts = [Buffer.from([ + (txid >> 8) & 0xFF, txid & 0xFF, + 0x01, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ])]; + for (const label of labels) { + parts.push(Buffer.from([label.length])); + parts.push(Buffer.from(label, 'utf8')); + } + parts.push(Buffer.from([ + 0x00, + (qtype >> 8) & 0xFF, qtype & 0xFF, + 0x00, 0x01, + ])); + return Buffer.concat(parts); +} + +function buildAReply(txid, qname, ttlSec, ip) { + const labels = qname.split('.').filter((s) => s.length > 0); + const parts = [Buffer.from([ + (txid >> 8) & 0xFF, txid & 0xFF, + 0x81, 0x80, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + ])]; + for (const label of labels) { + parts.push(Buffer.from([label.length])); + parts.push(Buffer.from(label, 'utf8')); + } + parts.push(Buffer.from([ + 0x00, + 0x00, 0x01, 0x00, 0x01, + 0xC0, 0x0C, + 0x00, 0x01, 0x00, 0x01, + (ttlSec >>> 24) & 0xFF, (ttlSec >>> 16) & 0xFF, + (ttlSec >>> 8) & 0xFF, ttlSec & 0xFF, + 0x00, 0x04, + ip[0], ip[1], ip[2], ip[3], + ])); + return Buffer.concat(parts); +} + +// =============== Runner =============== + +let passed = 0; +function check(label, cond, detail) { + if (!cond) { + console.error('FAIL: ' + label + (detail ? ' — ' + detail : '')); + process.exit(1); + } +} +function ok() { console.log(' ok'); passed++; } + +// =============== Tests =============== + +console.log('TEST B1 _sha256Hex returns 64 hex chars, deterministic'); +{ + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(makeCache()), + UrlFetchApp: makeUrlFetchApp(() => null).handle, + ContentService: makeContentService(), + }); + const h = ctx._sha256Hex('example.com'); + check('length 64', h.length === 64, 'len ' + h.length); + check('hex only', /^[0-9a-f]+$/.test(h), h); + check('deterministic', ctx._sha256Hex('example.com') === h); + ok(); +} + +console.log('TEST B2 _edgeDnsPrepare short qname → readable key'); +{ + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(makeCache()), + UrlFetchApp: makeUrlFetchApp(() => null).handle, + ContentService: makeContentService(), + }); + const prep = ctx._edgeDnsPrepare({ + d: buildQuery(0x1234, 'example.com', 1).toString('base64'), + }); + check('not null', prep !== null); + check('readable key', prep.key === 'edns:1:example.com', prep.key); + check('parsed qtype', prep.q.qtype === 1); + check('parsed txid', prep.q.txid === 0x1234); + ok(); +} + +console.log('TEST B3 _edgeDnsPrepare long qname → SHA-256 hashed key'); +{ + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(makeCache()), + UrlFetchApp: makeUrlFetchApp(() => null).handle, + ContentService: makeContentService(), + }); + const longName = 'a'.repeat(60) + '.' + 'b'.repeat(60) + '.' + + 'c'.repeat(60) + '.' + 'd'.repeat(60); + const prep = ctx._edgeDnsPrepare({ + d: buildQuery(0x1234, longName, 1).toString('base64'), + }); + check('not null (no longer bails on long qname)', prep !== null); + check('hashed namespace', prep.key.indexOf('edns:h:1:') === 0, prep.key); + // edns:h:1: (9) + 64 hex = 73 chars; well under the 250-char CacheService cap. + check('hashed length 73', prep.key.length === 73, 'len ' + prep.key.length); + ok(); +} + +console.log('TEST B4 _edgeDnsPrepare rejects qtype ANY (255)'); +{ + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(makeCache()), + UrlFetchApp: makeUrlFetchApp(() => null).handle, + ContentService: makeContentService(), + }); + const prep = ctx._edgeDnsPrepare({ + d: buildQuery(0x1234, 'example.com', 255).toString('base64'), + }); + check('null', prep === null); + ok(); +} + +console.log('TEST B5 _doTunnelBatch all-served-from-cache: zero outbound fetch'); +{ + const cache = makeCache(); + cache.handle.put( + 'edns:1:example.com', + buildAReply(0xCAFE, 'example.com', 300, [1, 2, 3, 4]).toString('base64'), + 300); + const utf = makeUrlFetchApp(() => { + throw new Error('UrlFetchApp must not be invoked when batch is all-cached'); + }); + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(cache), + UrlFetchApp: utf.handle, + ContentService: makeContentService(), + }); + const out = ctx._doTunnelBatch({ + ops: [{ + op: 'udp_open', port: 53, + d: buildQuery(0xBEEF, 'example.com', 1).toString('base64'), + }], + }); + check('no UrlFetchApp call', utf.calls().length === 0); + check('exactly one getAll', cache.getAllCalls() === 1); + const parsed = JSON.parse(out._text); + check('one result', parsed.r && parsed.r.length === 1); + check('cache sid', parsed.r[0].sid === 'edns-cache'); + // Verify the returned packet carries the requestor's txid (0xBEEF), not + // the txid that was stored in the cache (0xCAFE). + const pkt = bytesArr(Buffer.from(parsed.r[0].pkts[0], 'base64')); + check('txid hi rewritten', pkt[0] === 0xBE, 'got ' + pkt[0]); + check('txid lo rewritten', pkt[1] === 0xEF, 'got ' + pkt[1]); + ok(); +} + +console.log('TEST B6 _doTunnelBatch all-non-DNS: forwarded verbatim'); +{ + const cache = makeCache(); + const utf = makeUrlFetchApp(() => ({ + getResponseCode: () => 200, + getContent: () => Buffer.alloc(0), + getContentText: () => '{"r":[{"sid":"tcp-1"}]}', + })); + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(cache), + UrlFetchApp: utf.handle, + ContentService: makeContentService(), + }); + const out = ctx._doTunnelBatch({ + ops: [{ op: 'connect', host: 'a.com', port: 80 }], + }); + check('one fetch', utf.calls().length === 1); + check('went to /tunnel/batch', + utf.calls()[0].url.indexOf('/tunnel/batch') >= 0); + check('getAll skipped (no candidates)', cache.getAllCalls() === 0); + check('verbatim body', out._text === '{"r":[{"sid":"tcp-1"}]}'); + ok(); +} + +console.log('TEST B7 _doTunnelBatch mixed: forwarded subset + spliced ordering'); +{ + const cache = makeCache(); + cache.handle.put( + 'edns:1:example.com', + buildAReply(0xAAAA, 'example.com', 300, [1, 2, 3, 4]).toString('base64'), + 300); + const utf = makeUrlFetchApp((url, opts) => { + const body = JSON.parse(opts.payload); + check('forward carries non-DNS only', body.ops.length === 2); + check('forward op[0] is connect', body.ops[0].op === 'connect'); + check('forward op[1] is udp_data', body.ops[1].op === 'udp_data'); + return { + getResponseCode: () => 200, + getContent: () => Buffer.alloc(0), + getContentText: () => + JSON.stringify({ r: [{ sid: 'tcp-A' }, { sid: 'udp-Z' }] }), + }; + }); + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(cache), + UrlFetchApp: utf.handle, + ContentService: makeContentService(), + }); + const out = ctx._doTunnelBatch({ + ops: [ + { op: 'connect', host: 'a.com', port: 80 }, + { op: 'udp_open', port: 53, + d: buildQuery(0xBEEF, 'example.com', 1).toString('base64') }, + { op: 'udp_data', sid: 'u1', d: 'AAAA' }, + ], + }); + const parsed = JSON.parse(out._text); + check('three results', parsed.r.length === 3); + check('idx 0 = tcp-A', parsed.r[0].sid === 'tcp-A'); + check('idx 1 = edns', parsed.r[1].sid === 'edns-cache'); + check('idx 2 = udp-Z', parsed.r[2].sid === 'udp-Z'); + ok(); +} + +console.log('TEST B8 _doTunnelBatch getAll throws: DoH still runs, no put'); +{ + const cache = makeCache({ throwOnGetAll: true }); + const replyBytes = buildAReply(0xAAAA, 'example.com', 300, [1, 2, 3, 4]); + let dohCalls = 0; + const utf = makeUrlFetchApp((url) => { + if (url.indexOf('dns-query') >= 0) { + dohCalls++; + return { + getResponseCode: () => 200, + getContent: () => bytesArr(replyBytes), + }; + } + throw new Error('unexpected fetch ' + url); + }); + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(cache), + UrlFetchApp: utf.handle, + ContentService: makeContentService(), + }); + const out = ctx._doTunnelBatch({ + ops: [{ + op: 'udp_open', port: 53, + d: buildQuery(0xBEEF, 'example.com', 1).toString('base64'), + }], + }); + check('getAll attempted', cache.getAllCalls() === 1); + check('one DoH call', dohCalls === 1); + // cache==null was assigned in the catch path, so no put should fire. + check('no cache.put', cache.putHistory().length === 0); + const parsed = JSON.parse(out._text); + check('result is doh (not forwarded)', parsed.r[0].sid === 'edns-doh'); + ok(); +} + +console.log('TEST B9 _doTunnelBatch intra-batch dedup: one DoH for two same-key ops'); +{ + const cache = makeCache(); + const replyBytes = buildAReply(0xAAAA, 'example.com', 300, [1, 2, 3, 4]); + let dohCalls = 0; + const utf = makeUrlFetchApp((url) => { + if (url.indexOf('dns-query') >= 0) { + dohCalls++; + return { + getResponseCode: () => 200, + getContent: () => bytesArr(replyBytes), + }; + } + throw new Error('unexpected fetch ' + url); + }); + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(cache), + UrlFetchApp: utf.handle, + ContentService: makeContentService(), + }); + const out = ctx._doTunnelBatch({ + ops: [ + { op: 'udp_open', port: 53, + d: buildQuery(0x1111, 'example.com', 1).toString('base64') }, + { op: 'udp_open', port: 53, + d: buildQuery(0x2222, 'example.com', 1).toString('base64') }, + ], + }); + const parsed = JSON.parse(out._text); + check('only one DoH call', dohCalls === 1, 'got ' + dohCalls); + check('two results', parsed.r.length === 2); + check('first is doh', parsed.r[0].sid === 'edns-doh'); + // Second hits the in-batch dedup map (same code path as a real cache hit). + check('second is cache (intra-batch hit)', + parsed.r[1].sid === 'edns-cache'); + // Each result still carries its own request txid. + const pkt1 = bytesArr(Buffer.from(parsed.r[0].pkts[0], 'base64')); + const pkt2 = bytesArr(Buffer.from(parsed.r[1].pkts[0], 'base64')); + check('pkt1 txid', pkt1[0] === 0x11 && pkt1[1] === 0x11); + check('pkt2 txid', pkt2[0] === 0x22 && pkt2[1] === 0x22); + ok(); +} + +console.log('TEST B10 _edgeDnsResolve: corrupt cache value falls through to DoH'); +{ + const replyBytes = buildAReply(0xAAAA, 'example.com', 300, [1, 2, 3, 4]); + let dohCalls = 0; + const utf = makeUrlFetchApp(() => { + dohCalls++; + return { + getResponseCode: () => 200, + getContent: () => bytesArr(replyBytes), + }; + }); + const ctx = buildContext({ + Utilities: makeUtilities(), + CacheService: makeCacheService(makeCache()), + UrlFetchApp: utf.handle, + ContentService: makeContentService(), + }); + const prep = ctx._edgeDnsPrepare({ + d: buildQuery(0xBEEF, 'example.com', 1).toString('base64'), + }); + // <12-byte payload — the function bails on length and falls to DoH. + const corruptB64 = Buffer.from([0x01, 0x02, 0x03]).toString('base64'); + const synth = ctx._edgeDnsResolve(prep, corruptB64, null, null); + check('synth not null', synth !== null); + check('fell through to DoH', synth.sid === 'edns-doh'); + check('one DoH call', dohCalls === 1); + ok(); +} + +console.log('\n' + passed + ' tests passed');