diff --git a/assets/apps_script/CodeFull.gs b/assets/apps_script/CodeFull.gs index e116ee7..e47c64f 100644 --- a/assets/apps_script/CodeFull.gs +++ b/assets/apps_script/CodeFull.gs @@ -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; +} diff --git a/assets/apps_script/tests/edge_dns_test.js b/assets/apps_script/tests/edge_dns_test.js new file mode 100644 index 0000000..f59c757 --- /dev/null +++ b/assets/apps_script/tests/edge_dns_test.js @@ -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');