-
Notifications
You must be signed in to change notification settings - Fork 133
Expand file tree
/
Copy pathCodeFull.gs
More file actions
564 lines (508 loc) · 18.7 KB
/
CodeFull.gs
File metadata and controls
564 lines (508 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
/**
* DomainFront Relay + Full Tunnel — Google Apps Script
*
* FOUR modes:
* 1. Single relay: POST { k, m, u, h, b, ct, r } → { s, h, b }
* 2. Batch relay: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
* 3. Tunnel: POST { k, t, h, p, sid, d } → { sid, d, eof }
* 4. Tunnel batch: POST { k, t:"batch", ops:[...] } → { r: [...] }
* Batch ops include TCP (`connect`, `data`) and UDP (`udp_open`,
* `udp_data`) tunnel-node operations.
*
* CHANGE THESE TO YOUR OWN VALUES!
*/
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
const TUNNEL_SERVER_URL = "https://YOUR_TUNNEL_NODE_URL";
const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY";
// Active-probing defense. When false (production default), bad AUTH_KEY
// requests get a decoy HTML page that looks like a placeholder Apps
// Script web app instead of the JSON `{"e":"unauthorized"}` body. This
// makes the deployment indistinguishable from a forgotten-but-public
// Apps Script project to active scanners that POST malformed payloads
// looking for proxy endpoints.
//
// Set to `true` during initial setup if a misconfigured client is
// hitting "unauthorized" and you want the explicit JSON error to debug
// — then flip back to false before the deployment is widely shared.
// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.)
const DIAGNOSTIC_MODE = false;
const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 1,
};
// 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.
const DECOY_HTML =
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
'<body><p>The script completed but did not return anything.</p>' +
'</body></html>';
function _decoyOrError(jsonBody) {
if (DIAGNOSTIC_MODE) return _json(jsonBody);
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}
// Edge DNS cache. Plain UDP/53 queries normally traverse the full
// client → GAS → tunnel-node → public resolver path, and the
// trans-Atlantic round-trip dominates first-hop latency. When
// ENABLE_EDGE_DNS_CACHE is true, _doTunnelBatch intercepts udp_open
// ops with port=53, serves the reply from CacheService on a hit, or
// 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.
const ENABLE_EDGE_DNS_CACHE = true;
// DoH endpoints tried in order on cache miss. All speak RFC 8484
// over GET. Apps Script's outbound network peers well to all three.
const EDGE_DNS_RESOLVERS = [
"https://1.1.1.1/dns-query",
"https://dns.google/dns-query",
"https://dns.quad9.net/dns-query",
];
// CacheService bounds: 6h max TTL, 100KB per value, ~1000 keys, 250-char keys.
const EDGE_DNS_MIN_TTL_S = 30;
const EDGE_DNS_MAX_TTL_S = 21600; // 6h CacheService ceiling
// Used for NXDOMAIN/SERVFAIL and the rare "no answer + no SOA in authority"
// case. NOERROR/NODATA replies normally carry an SOA, and per RFC 2308 §5
// we honor that SOA's TTL via _dnsMinTtl (the positive path).
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.
const EDGE_DNS_MAX_KEY_LEN = 240;
// qtypes we refuse to cache and pass through to the tunnel-node:
// 255 = ANY (resolvers handle it more correctly than we would)
const EDGE_DNS_REFUSE_QTYPES = { 255: 1 };
// ========================== Entry point ==========================
function doPost(e) {
try {
var req = JSON.parse(e.postData.contents);
if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" });
// Tunnel mode
if (req.t) return _doTunnel(req);
// Batch relay mode
if (Array.isArray(req.q)) return _doBatch(req.q);
// Single relay mode
return _doSingle(req);
} catch (err) {
// Parse failures of the request body are also probe-shaped — a real
// mhrv-rs client never sends invalid JSON. Decoy for the same reason.
return _decoyOrError({ e: String(err) });
}
}
// ========================== Tunnel mode ==========================
function _doTunnel(req) {
// Batch tunnel: { k, t:"batch", ops:[...] }
if (req.t === "batch") {
return _doTunnelBatch(req);
}
// Single tunnel op
var payload = { k: TUNNEL_AUTH_KEY };
switch (req.t) {
case "connect":
payload.op = "connect";
payload.host = req.h;
payload.port = req.p;
break;
case "connect_data":
payload.op = "connect_data";
payload.host = req.h;
payload.port = req.p;
if (req.d) payload.data = req.d;
break;
case "data":
payload.op = "data";
payload.sid = req.sid;
if (req.d) payload.data = req.d;
break;
case "close":
payload.op = "close";
payload.sid = req.sid;
break;
default:
// Structured `code` lets the Rust client detect version skew
// without substring-matching the error text. Must match
// CODE_UNSUPPORTED_OP in tunnel_client.rs and tunnel-node/src/main.rs.
return _json({ e: "unknown tunnel op: " + req.t, code: "UNSUPPORTED_OP" });
}
var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel", {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true,
followRedirects: true,
});
if (resp.getResponseCode() !== 200) {
return _json({ e: "tunnel node HTTP " + resp.getResponseCode() });
}
return ContentService.createTextOutput(resp.getContentText())
.setMimeType(ContentService.MimeType.JSON);
}
// 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.
function _doTunnelBatch(req) {
var ops = (req && req.ops) || [];
// Feature off: byte-identical to the pre-feature behavior.
if (!ENABLE_EDGE_DNS_CACHE) {
return _doTunnelBatchForward(ops);
}
var results = new Array(ops.length); // sparse: filled by edge-DNS hits
var forwardOps = [];
var forwardIdx = [];
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;
continue;
}
}
forwardOps.push(op);
forwardIdx.push(i);
}
// All ops served locally — no tunnel-node round-trip.
if (forwardOps.length === 0) {
return _json({ r: results });
}
// Nothing was served locally — forward verbatim, no splice needed.
if (forwardOps.length === ops.length) {
return _doTunnelBatchForward(ops);
}
// Partial: forward the un-served ops and splice results back in place.
var resp = _doTunnelBatchFetch(forwardOps);
if (resp.error) return _json({ e: resp.error });
if (resp.r.length !== forwardOps.length) {
// Tunnel-node version skew — bail explicitly rather than silently
// route TCP responses to UDP sids.
return _json({ e: "tunnel batch length mismatch" });
}
return _json({ r: _spliceTunnelResults(forwardIdx, resp.r, results) });
}
// Verbatim forward: no splice, response passed through unchanged.
function _doTunnelBatchForward(ops) {
var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", {
method: "post",
contentType: "application/json",
payload: JSON.stringify({ k: TUNNEL_AUTH_KEY, ops: ops }),
muteHttpExceptions: true,
followRedirects: true,
});
if (resp.getResponseCode() !== 200) {
return _json({ e: "tunnel batch HTTP " + resp.getResponseCode() });
}
return ContentService.createTextOutput(resp.getContentText())
.setMimeType(ContentService.MimeType.JSON);
}
// Forward + parse for the splice path. Returns { r:[...] } on success or
// { error: "..." } on any failure.
function _doTunnelBatchFetch(ops) {
var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", {
method: "post",
contentType: "application/json",
payload: JSON.stringify({ k: TUNNEL_AUTH_KEY, ops: ops }),
muteHttpExceptions: true,
followRedirects: true,
});
if (resp.getResponseCode() !== 200) {
return { error: "tunnel batch HTTP " + resp.getResponseCode() };
}
try {
var parsed = JSON.parse(resp.getContentText());
return { r: (parsed && parsed.r) || [] };
} catch (err) {
return { error: "tunnel batch parse error" };
}
}
// Pure helper: writes forwardedResults[j] into allResults[forwardIdx[j]]
// for each j. Returns the mutated allResults so callers can chain. Pure
// function — testable without the GAS runtime.
function _spliceTunnelResults(forwardIdx, forwardedResults, allResults) {
for (var j = 0; j < forwardIdx.length; j++) {
allResults[forwardIdx[j]] = forwardedResults[j];
}
return allResults;
}
// ========================== HTTP relay mode ==========================
function _doSingle(req) {
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
return _json({ e: "bad url" });
}
var opts = _buildOpts(req);
var resp = UrlFetchApp.fetch(req.u, opts);
return _json({
s: resp.getResponseCode(),
h: _respHeaders(resp),
b: Utilities.base64Encode(resp.getContent()),
});
}
function _doBatch(items) {
var fetchArgs = [];
var errorMap = {};
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
errorMap[i] = "bad url";
continue;
}
var opts = _buildOpts(item);
opts.url = item.u;
fetchArgs.push({ _i: i, _o: opts });
}
var responses = [];
if (fetchArgs.length > 0) {
responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; }));
}
var results = [];
var rIdx = 0;
for (var i = 0; i < items.length; i++) {
if (errorMap.hasOwnProperty(i)) {
results.push({ e: errorMap[i] });
} else {
var resp = responses[rIdx++];
results.push({
s: resp.getResponseCode(),
h: _respHeaders(resp),
b: Utilities.base64Encode(resp.getContent()),
});
}
}
return _json({ q: results });
}
// ========================== Helpers ==========================
function _buildOpts(req) {
var opts = {
method: (req.m || "GET").toLowerCase(),
muteHttpExceptions: true,
followRedirects: req.r !== false,
validateHttpsCertificates: true,
escaping: false,
};
if (req.h && typeof req.h === "object") {
var headers = {};
for (var k in req.h) {
if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
headers[k] = req.h[k];
}
}
opts.headers = headers;
}
if (req.b) {
opts.payload = Utilities.base64Decode(req.b);
if (req.ct) opts.contentType = req.ct;
}
return opts;
}
function _respHeaders(resp) {
try {
if (typeof resp.getAllHeaders === "function") {
return resp.getAllHeaders();
}
} catch (err) {}
return resp.getHeaders();
}
function doGet(e) {
return HtmlService.createHtmlOutput(
"<!DOCTYPE html><html><head><title>My App</title></head>" +
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
"<h1>Welcome</h1><p>This application is running normally.</p>" +
"</body></html>"
);
}
function _json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
ContentService.MimeType.JSON
);
}
// ========================== 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) {
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();
var stored = null;
try { stored = cache.get(key); } catch (_) {}
if (stored) {
try {
var hit = Utilities.base64Decode(stored);
if (hit && hit.length >= 12) {
// Rewrite txid to match this query (RFC 1035 §4.1.1).
var rewritten = _dnsRewriteTxid(hit, q.txid);
return {
sid: "edns-cache",
pkts: [Utilities.base64Encode(rewritten)],
eof: true,
};
}
} catch (_) { /* corrupt cache entry — fall through to DoH */ }
}
for (var i = 0; i < EDGE_DNS_RESOLVERS.length; i++) {
var reply = _edgeDnsDoh(EDGE_DNS_RESOLVERS[i], bytes);
if (!reply) continue;
var rcode = reply[3] & 0x0F;
var ttl;
if (rcode === 2 || rcode === 3) {
ttl = EDGE_DNS_NEG_TTL_S;
} else {
var minTtl = _dnsMinTtl(reply);
ttl = (minTtl === null) ? EDGE_DNS_NEG_TTL_S : minTtl;
if (ttl < EDGE_DNS_MIN_TTL_S) ttl = EDGE_DNS_MIN_TTL_S;
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.
}
// The DoH reply already echoes our query's txid; rewrite defensively
// in case a resolver mangles it.
var fixed = _dnsRewriteTxid(reply, q.txid);
return {
sid: "edns-doh",
pkts: [Utilities.base64Encode(fixed)],
eof: true,
};
}
return null;
} catch (err) {
return null;
}
}
// 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) {
try {
var dns = Utilities.base64EncodeWebSafe(queryBytes).replace(/=+$/, "");
var resp = UrlFetchApp.fetch(url + "?dns=" + dns, {
method: "get",
muteHttpExceptions: true,
followRedirects: true,
headers: { accept: "application/dns-message" },
});
if (resp.getResponseCode() !== 200) return null;
var body = resp.getContent();
if (!body || body.length < 12) return null;
return body;
} catch (err) {
return null;
}
}
// Returns { txid, qname, qtype } from a DNS wire-format query.
// qname is lowercased and dot-joined (no trailing dot). Null on malformed.
function _dnsParseQuestion(bytes) {
if (bytes.length < 12) return null;
var qdcount = ((bytes[4] & 0xFF) << 8) | (bytes[5] & 0xFF);
// RFC ambiguity: multi-question queries are essentially unused in
// practice and would mis-key the cache (we'd cache a multi-answer reply
// under only the first question). Bail and let the tunnel-node handle it.
if (qdcount !== 1) return null;
var off = 12;
var labels = [];
var nameLen = 0;
while (off < bytes.length) {
var len = bytes[off] & 0xFF;
if (len === 0) { off++; break; }
if ((len & 0xC0) !== 0) return null; // questions don't use compression
if (len > 63) return null;
off++;
if (off + len > bytes.length) return null;
var label = "";
for (var i = 0; i < len; i++) {
var c = bytes[off + i] & 0xFF;
if (c >= 0x41 && c <= 0x5A) c += 0x20; // ASCII lowercase
label += String.fromCharCode(c);
}
labels.push(label);
off += len;
nameLen += len + 1;
if (nameLen > 255) return null;
}
if (off + 4 > bytes.length) return null;
var qtype = ((bytes[off] & 0xFF) << 8) | (bytes[off + 1] & 0xFF);
return {
txid: ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF),
qname: labels.join("."),
qtype: qtype,
};
}
// Walks the DNS reply's answer + authority sections and returns the min RR
// TTL, or null if there are no RRs (caller treats null as "use neg TTL").
// Returns null on any malformed input.
function _dnsMinTtl(bytes) {
if (bytes.length < 12) return null;
var qdcount = ((bytes[4] & 0xFF) << 8) | (bytes[5] & 0xFF);
var ancount = ((bytes[6] & 0xFF) << 8) | (bytes[7] & 0xFF);
var nscount = ((bytes[8] & 0xFF) << 8) | (bytes[9] & 0xFF);
var off = 12;
for (var q = 0; q < qdcount; q++) {
off = _dnsSkipName(bytes, off);
if (off < 0 || off + 4 > bytes.length) return null;
off += 4;
}
var min = null;
var rrTotal = ancount + nscount;
for (var r = 0; r < rrTotal; r++) {
off = _dnsSkipName(bytes, off);
if (off < 0 || off + 10 > bytes.length) return null;
// 2B type, 2B class, 4B TTL, 2B rdlength
var ttl = ((bytes[off + 4] & 0xFF) * 0x1000000)
+ (((bytes[off + 5] & 0xFF) << 16)
| ((bytes[off + 6] & 0xFF) << 8)
| (bytes[off + 7] & 0xFF));
// RFC 2181: TTLs are 32-bit unsigned; values with the top bit set are
// treated as 0. Multiplying the high byte (instead of <<24) avoids V8
// sign-extension and keeps `ttl` in [0, 2^32).
if (ttl < 0 || ttl > 0x7FFFFFFF) ttl = 0;
if (min === null || ttl < min) min = ttl;
var rdlen = ((bytes[off + 8] & 0xFF) << 8) | (bytes[off + 9] & 0xFF);
off += 10 + rdlen;
if (off > bytes.length) return null;
}
return min;
}
// Advances past a DNS name (sequence of labels or 16-bit pointer).
// Returns the new offset, or -1 on malformed input.
function _dnsSkipName(bytes, off) {
while (off < bytes.length) {
var len = bytes[off] & 0xFF;
if (len === 0) return off + 1;
if ((len & 0xC0) === 0xC0) {
if (off + 2 > bytes.length) return -1;
return off + 2; // pointer terminates the name in-place
}
if ((len & 0xC0) !== 0) return -1; // reserved label type
if (len > 63) return -1;
off += 1 + len;
}
return -1;
}
// Returns a copy of `bytes` with the first 2 bytes overwritten by the
// 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.
function _dnsRewriteTxid(bytes, txid) {
var out = [];
for (var i = 0; i < bytes.length; i++) out.push(bytes[i]);
var hi = (txid >> 8) & 0xFF;
var lo = txid & 0xFF;
out[0] = hi > 127 ? hi - 256 : hi;
out[1] = lo > 127 ? lo - 256 : lo;
return out;
}