Skip to content

Commit aad900e

Browse files
authored
feat(codefull.gs): edge-cache DNS to skip tunnel-node round-trip (#494)
Edge DNS caching at the Apps Script layer using CacheService. By @dazzling-no-more. Intercepts `udp_open`/port=53 ops in `_doTunnelBatch` and serves them from CacheService (cache hit) or DoH (cache miss). Cache hits drop typical first-hop DNS latency from ~600-1200ms to ~200-400ms. - DoH fallback chain: Cloudflare → Google → Quad9 over RFC 8484 GET - Per-qtype cache key keeps A and AAAA from colliding - Min RR TTL clamped to [30s, 6h]; NXDOMAIN/SERVFAIL get 45s negative cache; NODATA-with-SOA honors SOA TTL per RFC 2308 §5 - Splice helper preserves op-index ordering across mixed TCP+DNS batches - Default-on, opt-out via `ENABLE_EDGE_DNS_CACHE`; every failure mode falls through to existing tunnel-node forward path (zero regression) - Privacy-aware: CacheService is volatile + has no on-disk artifact (vs Sheets which would persist a Drive-listed log of every domain users resolve) 11 pure-JS tests covering parsers, txid non-mutation, TTL high-bit clamp, NXDOMAIN-with-SOA TTL extraction, malformed/truncated input rejection, splice correctness for mixed batches. All 160 Rust lib tests still passing.
2 parents d959306 + 72ed6d8 commit aad900e

2 files changed

Lines changed: 535 additions & 8 deletions

File tree

assets/apps_script/CodeFull.gs

Lines changed: 323 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

5595
function 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.
130172
function _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

154267
function _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

Comments
 (0)