mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
feat(codefull.gs): edge-cache DNS to skip tunnel-node round-trip
This commit is contained in:
@@ -50,6 +50,46 @@ function _decoyOrError(jsonBody) {
|
||||
.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) {
|
||||
@@ -126,29 +166,102 @@ function _doTunnel(req) {
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
|
||||
// Batch tunnel: forward all ops in one request to /tunnel/batch
|
||||
// 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 payload = {
|
||||
k: TUNNEL_AUTH_KEY,
|
||||
ops: req.ops || [],
|
||||
};
|
||||
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(payload),
|
||||
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) {
|
||||
@@ -247,3 +360,205 @@ function _json(obj) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Pure-JS sanity tests for the edge DNS cache helpers in CodeFull.gs.
|
||||
//
|
||||
// Run from repo root: node assets/apps_script/tests/edge_dns_test.js
|
||||
//
|
||||
// The tests extract the helpers that don't depend on the GAS runtime
|
||||
// (Utilities, CacheService, UrlFetchApp) and exercise them against
|
||||
// crafted DNS wire-format payloads. They catch the bugs most likely to
|
||||
// regress when editing the parser: txid handling, name-pointer
|
||||
// compression, TTL sign-extension, splice ordering with mixed batches.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.join(__dirname, '..', 'CodeFull.gs');
|
||||
const src = fs.readFileSync(SRC, 'utf8');
|
||||
|
||||
// Extract pure-JS helpers and eval them in a shared scope so cross-refs
|
||||
// (_dnsMinTtl → _dnsSkipName) resolve.
|
||||
const NAMES = [
|
||||
'_dnsSkipName',
|
||||
'_dnsParseQuestion',
|
||||
'_dnsMinTtl',
|
||||
'_dnsRewriteTxid',
|
||||
'_spliceTunnelResults',
|
||||
];
|
||||
let bundle = '';
|
||||
for (const name of NAMES) {
|
||||
const re = new RegExp(`function ${name}\\b[\\s\\S]*?\\n\\}\\n`, 'g');
|
||||
const m = src.match(re);
|
||||
if (!m) throw new Error('helper not found in CodeFull.gs: ' + name);
|
||||
bundle += m[0] + '\n';
|
||||
}
|
||||
bundle += `return { ${NAMES.join(', ')} };`;
|
||||
// eslint-disable-next-line no-new-func
|
||||
const ctx = new Function(bundle)();
|
||||
|
||||
let passed = 0;
|
||||
function ok(label) { console.log(' ok'); passed++; }
|
||||
function check(label, cond, detail) {
|
||||
if (!cond) {
|
||||
console.error('FAIL: ' + label + (detail ? ' — ' + detail : ''));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 1. parse a query for example.com A ---
|
||||
const q1 = Buffer.from([
|
||||
0x12, 0x34, // txid
|
||||
0x01, 0x00, // flags: RD=1
|
||||
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // counts
|
||||
0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // "example"
|
||||
0x03, 0x63, 0x6f, 0x6d, 0x00, // "com" 0
|
||||
0x00, 0x01, 0x00, 0x01, // qtype=A, qclass=IN
|
||||
]);
|
||||
console.log('TEST 1 query parse');
|
||||
const r1 = ctx._dnsParseQuestion(q1);
|
||||
check('txid', r1.txid === 0x1234, r1 && r1.txid.toString(16));
|
||||
check('qname', r1.qname === 'example.com', r1 && r1.qname);
|
||||
check('qtype', r1.qtype === 1);
|
||||
ok();
|
||||
|
||||
// --- 2. case-fold (DNS names are case-insensitive on the wire) ---
|
||||
const q2 = Buffer.from([
|
||||
0xab, 0xcd, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x07, 0x45, 0x58, 0x41, 0x4d, 0x50, 0x4c, 0x45, // "EXAMPLE"
|
||||
0x03, 0x43, 0x4f, 0x4d, 0x00, // "COM" 0
|
||||
0x00, 0x1c, 0x00, 0x01, // qtype=AAAA(28)
|
||||
]);
|
||||
console.log('TEST 2 case-fold to lowercase');
|
||||
const r2 = ctx._dnsParseQuestion(q2);
|
||||
check('lowercased qname', r2.qname === 'example.com', r2 && r2.qname);
|
||||
check('qtype AAAA', r2.qtype === 28);
|
||||
ok();
|
||||
|
||||
// --- 3. txid rewrite preserves all other bytes ---
|
||||
console.log('TEST 3 txid rewrite is byte-identical except [0..1]');
|
||||
const rewritten = ctx._dnsRewriteTxid(q1, 0xdead);
|
||||
check('hi byte', (rewritten[0] & 0xFF) === 0xde);
|
||||
check('lo byte', (rewritten[1] & 0xFF) === 0xad);
|
||||
check('length', rewritten.length === q1.length);
|
||||
for (let i = 2; i < q1.length; i++) {
|
||||
check('byte ' + i + ' unchanged', (rewritten[i] & 0xFF) === q1[i]);
|
||||
}
|
||||
check('source not mutated (cache safety)',
|
||||
q1[0] === 0x12 && q1[1] === 0x34, 'source bytes 0..1 = ' + q1[0] + ',' + q1[1]);
|
||||
ok();
|
||||
|
||||
// --- 4. min-TTL extraction with answer name-pointer compression ---
|
||||
const reply4 = Buffer.from([
|
||||
0x12, 0x34, 0x81, 0x80,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x03, 0x63, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0xc0, 0x0c, // pointer to QNAME
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0x00, 0x00, 0x01, 0x2c, // TTL=300
|
||||
0x00, 0x04,
|
||||
0x5d, 0xb8, 0xd8, 0x22, // 93.184.216.34
|
||||
]);
|
||||
console.log('TEST 4 reply min-TTL (answer with pointer)');
|
||||
check('TTL=300', ctx._dnsMinTtl(reply4) === 300);
|
||||
ok();
|
||||
|
||||
// --- 5. NXDOMAIN with SOA in authority — TTL comes from authority RR ---
|
||||
const soa = Buffer.from([
|
||||
0x02, 0x6e, 0x73, 0x04, 0x74, 0x65, 0x73, 0x74, 0x00, // mname "ns.test."
|
||||
0x0a, 0x68, 0x6f, 0x73, 0x74, 0x6d, 0x61, 0x73, 0x74, 0x65, 0x72,
|
||||
0x04, 0x74, 0x65, 0x73, 0x74, 0x00, // rname
|
||||
0x00, 0x00, 0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x03,
|
||||
0x00, 0x00, 0x00, 0x04,
|
||||
0x00, 0x00, 0x00, 0x05,
|
||||
]);
|
||||
const nxHeader = Buffer.from([
|
||||
0x12, 0x34, 0x81, 0x83, // RCODE=3
|
||||
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||
0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, // "missing"
|
||||
0x04, 0x74, 0x65, 0x73, 0x74, 0x00, // "test"
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
]);
|
||||
const authRR = Buffer.concat([
|
||||
Buffer.from([0xc0, 0x14]), // pointer to "test"
|
||||
Buffer.from([0x00, 0x06, 0x00, 0x01]), // SOA / IN
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x3c]), // TTL=60
|
||||
Buffer.from([0x00, soa.length]),
|
||||
soa,
|
||||
]);
|
||||
const nxReply = Buffer.concat([nxHeader, authRR]);
|
||||
console.log('TEST 5 NXDOMAIN: rcode + SOA TTL parse');
|
||||
check('rcode 3', (nxReply[3] & 0x0F) === 3);
|
||||
check('soa TTL 60', ctx._dnsMinTtl(nxReply) === 60);
|
||||
ok();
|
||||
|
||||
// --- 6. malformed (truncated header) → null ---
|
||||
console.log('TEST 6 truncated input rejected');
|
||||
check('null', ctx._dnsParseQuestion(Buffer.from([0x00, 0x00, 0x01])) === null);
|
||||
ok();
|
||||
|
||||
// --- 7. illegal pointer in question section → null ---
|
||||
const q7 = Buffer.from([
|
||||
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xc0, 0x0c, // illegal in question
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
]);
|
||||
console.log('TEST 7 reject compression in question');
|
||||
check('null', ctx._dnsParseQuestion(q7) === null);
|
||||
ok();
|
||||
|
||||
// --- 8. TTL with high bit set is clamped to 0 (RFC 2181 §8) ---
|
||||
// Build a minimal A reply where the answer's 4-byte TTL field is 0x80000000.
|
||||
const reply8 = Buffer.from([
|
||||
0x12, 0x34, 0x81, 0x80,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x03, 0x63, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0xc0, 0x0c,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0x80, 0x00, 0x00, 0x00, // TTL with top bit set
|
||||
0x00, 0x04,
|
||||
0x01, 0x02, 0x03, 0x04,
|
||||
]);
|
||||
console.log('TEST 8 TTL with high bit → clamped to 0');
|
||||
const t8 = ctx._dnsMinTtl(reply8);
|
||||
check('TTL clamped to 0 (not negative, not 2^31+)', t8 === 0, 'got ' + t8);
|
||||
ok();
|
||||
|
||||
// --- 9. splice: forwarded results land at original op indices ---
|
||||
console.log('TEST 9 splice into mixed-batch slots');
|
||||
// Simulate a 5-op batch where indices 1 and 3 were served locally as DNS
|
||||
// hits, indices 0/2/4 were forwarded as TCP ops.
|
||||
const allResults = new Array(5);
|
||||
allResults[1] = { sid: 'edns-cache-1', pkts: ['A'], eof: true };
|
||||
allResults[3] = { sid: 'edns-doh-3', pkts: ['B'], eof: true };
|
||||
const forwardIdx = [0, 2, 4];
|
||||
const forwardedResults = [
|
||||
{ sid: 'tcp-0', d: 'X' },
|
||||
{ sid: 'tcp-2', d: 'Y' },
|
||||
{ sid: 'tcp-4', d: 'Z' },
|
||||
];
|
||||
const merged = ctx._spliceTunnelResults(forwardIdx, forwardedResults, allResults);
|
||||
check('slot 0 from tunnel', merged[0].sid === 'tcp-0');
|
||||
check('slot 1 from cache', merged[1].sid === 'edns-cache-1');
|
||||
check('slot 2 from tunnel', merged[2].sid === 'tcp-2');
|
||||
check('slot 3 from doh', merged[3].sid === 'edns-doh-3');
|
||||
check('slot 4 from tunnel', merged[4].sid === 'tcp-4');
|
||||
check('returns same array', merged === allResults);
|
||||
ok();
|
||||
|
||||
// --- 10. splice when nothing is forwarded ---
|
||||
console.log('TEST 10 splice with empty forward list');
|
||||
const allDns = [{ sid: 'a' }, { sid: 'b' }];
|
||||
const result10 = ctx._spliceTunnelResults([], [], allDns);
|
||||
check('no mutation', result10[0].sid === 'a' && result10[1].sid === 'b');
|
||||
ok();
|
||||
|
||||
// --- 11. splice when everything is forwarded ---
|
||||
console.log('TEST 11 splice with everything forwarded');
|
||||
const empty = new Array(3);
|
||||
const result11 = ctx._spliceTunnelResults(
|
||||
[0, 1, 2],
|
||||
[{ sid: 'x' }, { sid: 'y' }, { sid: 'z' }],
|
||||
empty,
|
||||
);
|
||||
check('all filled', result11[0].sid === 'x' && result11[2].sid === 'z');
|
||||
ok();
|
||||
|
||||
console.log('\n' + passed + ' tests passed');
|
||||
Reference in New Issue
Block a user