|
| 1 | +/** |
| 2 | + * admin SSE diag trimming — only ship the 4 fields the dashboard reads |
| 3 | + * |
| 4 | + * The admin Server-Sent-Events stream (GET /api/events) forwards full per-node |
| 5 | + * result rows to the admin browser. Each row's `diag` blob carries internal / |
| 6 | + * sensitive diagnostics — the V2Ray credential `v2rayUUID`, raw `v2rayConfig` / |
| 7 | + * `hsConfig`, process `v2rayStdout` / `v2rayStderr`, `wgServerEndpoint`, and |
| 8 | + * ~20 other fields. The admin dashboard (admin.html) reads `diag` off a LIVE |
| 9 | + * SSE row in exactly ONE place — the results-table transport-detail renderer — |
| 10 | + * and reads ONLY: v2rayProto / v2rayTransport / v2raySecurity / v2rayPort. The |
| 11 | + * rich node-detail drawer + failure-report builder source diag from the |
| 12 | + * persisted error-log over REST (`er.raw_json`), not the SSE row. |
| 13 | + * |
| 14 | + * So server.js trims each SSE-bound result row's diag to those 4 fields via |
| 15 | + * trimRowDiag(). The trim MUST be NON-MUTATING: result rows are shared |
| 16 | + * references with DB persistence (insertBatchResult → raw_json) and in-memory |
| 17 | + * state (getResults()); mutating would strip the diag the drawer/persistence |
| 18 | + * depend on. |
| 19 | + * |
| 20 | + * This test extracts the REAL trimRowDiag from server.js and asserts: |
| 21 | + * - the returned clone's diag has ONLY the 4 kept keys, none of the sensitive |
| 22 | + * ones, and carries the kept values through; |
| 23 | + * - the original row + its diag are UNTOUCHED, and the clone is a new object |
| 24 | + * with a new diag object; |
| 25 | + * - a row with no diag passes through unchanged (no fabricated diag key); |
| 26 | + * - a row with a falsy diag passes through unchanged. |
| 27 | + * |
| 28 | + * Run: node test/admin-sse-diag-trim.test.js |
| 29 | + */ |
| 30 | + |
| 31 | +import { readFileSync } from 'node:fs'; |
| 32 | +import { fileURLToPath } from 'node:url'; |
| 33 | +import { dirname, join } from 'node:path'; |
| 34 | +import vm from 'node:vm'; |
| 35 | + |
| 36 | +const out = { pass: 0, fail: 0, errors: [] }; |
| 37 | +function ok(cond, name) { |
| 38 | + if (cond) { out.pass++; console.log(` PASS ${name}`); } |
| 39 | + else { out.fail++; out.errors.push(name); console.log(` FAIL ${name}`); } |
| 40 | +} |
| 41 | + |
| 42 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 43 | +const src = readFileSync(join(__dirname, '..', 'server.js'), 'utf8'); |
| 44 | + |
| 45 | +// Balanced-brace function extractor (mirrors public-sse-fields / live-load-gating). |
| 46 | +function extractFn(s, name) { |
| 47 | + const m = new RegExp(`function\\s+${name}\\s*\\(`).exec(s); |
| 48 | + if (!m) throw new Error(`function ${name} not found in server.js`); |
| 49 | + let depth = 0, started = false, j = m.index; |
| 50 | + for (; j < s.length; j++) { |
| 51 | + const c = s[j]; |
| 52 | + if (c === '{') { depth++; started = true; } |
| 53 | + else if (c === '}') { depth--; if (started && depth === 0) { j++; break; } } |
| 54 | + } |
| 55 | + return s.slice(m.index, j); |
| 56 | +} |
| 57 | + |
| 58 | +// The extractor is a naive char counter: a brace inside a string/regex literal |
| 59 | +// would silently truncate the slice and turn into a confusing "field missing" |
| 60 | +// assertion. Guard loudly — the extracted helper must end with `}` and still |
| 61 | +// contain the last kept subfield (v2rayPort). |
| 62 | +function mustExtract(extracted, what, endToken, sentinel) { |
| 63 | + const trimmed = extracted.trimEnd(); |
| 64 | + if (!trimmed.endsWith(endToken)) { |
| 65 | + throw new Error(`mis-extracted ${what}: expected it to end with "${endToken}" ` + |
| 66 | + `but got "...${trimmed.slice(-40)}" (a brace inside a string or regex ` + |
| 67 | + `likely truncated the balanced-brace scan)`); |
| 68 | + } |
| 69 | + if (!extracted.includes(sentinel)) { |
| 70 | + throw new Error(`mis-extracted ${what}: expected it to contain "${sentinel}" ` + |
| 71 | + `(extraction stopped early before the last kept field)`); |
| 72 | + } |
| 73 | + return extracted; |
| 74 | +} |
| 75 | + |
| 76 | +const sandbox = { console }; |
| 77 | +vm.createContext(sandbox); |
| 78 | +vm.runInContext( |
| 79 | + mustExtract(extractFn(src, 'trimRowDiag'), 'trimRowDiag', '}', 'v2rayPort'), |
| 80 | + sandbox); |
| 81 | + |
| 82 | +const KEPT = ['v2rayProto', 'v2rayTransport', 'v2raySecurity', 'v2rayPort']; |
| 83 | +const SENSITIVE = [ |
| 84 | + 'v2rayUUID', 'v2rayConfig', 'v2rayStdout', 'v2rayStderr', |
| 85 | + 'hsConfig', 'wgServerEndpoint', 'googleError', 'sessionId', |
| 86 | +]; |
| 87 | + |
| 88 | +console.log('\nadmin SSE diag trimming — only the 4 consumed fields ship\n'); |
| 89 | + |
| 90 | +// ─── 1. trims diag to the 4 kept fields, drops every sensitive one ─────────── |
| 91 | +console.log('[1] trimRowDiag keeps the 4 fields, drops the credential blob'); |
| 92 | +{ |
| 93 | + const original = { |
| 94 | + address: 'sent1abc', moniker: 'Node A', actualMbps: 42.5, |
| 95 | + diag: { |
| 96 | + v2rayProto: 'vless', v2rayTransport: 'grpc', |
| 97 | + v2raySecurity: 'tls', v2rayPort: 443, |
| 98 | + // sensitive / internal — must NOT survive the trim: |
| 99 | + v2rayUUID: 'deadbeef-cred-uuid', v2rayConfig: '{ big json }', |
| 100 | + v2rayStdout: 'process stdout...', v2rayStderr: 'process stderr...', |
| 101 | + hsConfig: 'handshake config', wgServerEndpoint: '1.2.3.4:51820', |
| 102 | + googleError: 'some probe error', sessionId: 99887766, |
| 103 | + }, |
| 104 | + }; |
| 105 | + sandbox._row = original; |
| 106 | + const trimmed = vm.runInContext('trimRowDiag(_row)', sandbox); |
| 107 | + |
| 108 | + // exactly the 4 kept keys, nothing else |
| 109 | + const keys = Object.keys(trimmed.diag); |
| 110 | + ok(keys.length === 4, `trimmed diag has exactly 4 keys (got ${keys.length}: ${keys.join(',')})`); |
| 111 | + for (const k of KEPT) { |
| 112 | + ok(Object.prototype.hasOwnProperty.call(trimmed.diag, k), `keeps "${k}"`); |
| 113 | + } |
| 114 | + for (const k of SENSITIVE) { |
| 115 | + ok(!Object.prototype.hasOwnProperty.call(trimmed.diag, k), |
| 116 | + `DROPS sensitive "${k}" (regression guard)`); |
| 117 | + } |
| 118 | + |
| 119 | + // kept values carried through correctly |
| 120 | + ok(trimmed.diag.v2rayProto === 'vless', 'carries v2rayProto value'); |
| 121 | + ok(trimmed.diag.v2rayTransport === 'grpc', 'carries v2rayTransport value'); |
| 122 | + ok(trimmed.diag.v2raySecurity === 'tls', 'carries v2raySecurity value'); |
| 123 | + ok(trimmed.diag.v2rayPort === 443, 'carries v2rayPort value'); |
| 124 | + |
| 125 | + // top-level non-diag fields preserved |
| 126 | + ok(trimmed.address === 'sent1abc', 'preserves top-level address'); |
| 127 | + ok(trimmed.actualMbps === 42.5, 'preserves top-level actualMbps'); |
| 128 | + |
| 129 | + // ─── NON-MUTATION ───────────────────────────────────────────────────────── |
| 130 | + ok(trimmed !== original, 'returns a NEW row object (not the same reference)'); |
| 131 | + ok(trimmed.diag !== original.diag, 'returns a NEW diag object'); |
| 132 | + ok(original.diag.v2rayUUID === 'deadbeef-cred-uuid', |
| 133 | + 'original diag credential UNTOUCHED'); |
| 134 | + ok(Object.keys(original.diag).length === 12, |
| 135 | + `original diag still has all 12 keys (got ${Object.keys(original.diag).length})`); |
| 136 | +} |
| 137 | + |
| 138 | +// ─── 2. undefined kept subfields are preserved as-is (not fabricated) ──────── |
| 139 | +console.log('[2] undefined kept subfields pass through as undefined'); |
| 140 | +{ |
| 141 | + sandbox._row = { address: 'sent1p', diag: { v2rayProto: 'vmess' } }; |
| 142 | + const trimmed = vm.runInContext('trimRowDiag(_row)', sandbox); |
| 143 | + ok(trimmed.diag.v2rayProto === 'vmess', 'present field kept'); |
| 144 | + ok(trimmed.diag.v2rayPort === undefined, 'absent field stays undefined'); |
| 145 | + // simplest-correct form always carries the 4 keys (matches admin.html's |
| 146 | + // `r.diag.v2rayProto || ''` read) |
| 147 | + ok(Object.prototype.hasOwnProperty.call(trimmed.diag, 'v2rayPort'), |
| 148 | + 'always carries all 4 keys'); |
| 149 | +} |
| 150 | + |
| 151 | +// ─── 3. rows with no diag pass through untouched ───────────────────────────── |
| 152 | +console.log('[3] rows without a diag pass through unchanged (no fabricated key)'); |
| 153 | +{ |
| 154 | + const skip = { address: 'sent1skip', skipped: true }; |
| 155 | + sandbox._row = skip; |
| 156 | + const r = vm.runInContext('trimRowDiag(_row)', sandbox); |
| 157 | + ok(r === skip, 'no-diag row returns the SAME reference (no clone)'); |
| 158 | + ok(!Object.prototype.hasOwnProperty.call(r, 'diag'), |
| 159 | + 'no-diag row gains NO diag key'); |
| 160 | +} |
| 161 | + |
| 162 | +// ─── 4. falsy diag passes through unchanged ────────────────────────────────── |
| 163 | +console.log('[4] rows with a falsy diag pass through unchanged'); |
| 164 | +{ |
| 165 | + const nullDiag = { address: 'sent1n', diag: null }; |
| 166 | + sandbox._row = nullDiag; |
| 167 | + const r = vm.runInContext('trimRowDiag(_row)', sandbox); |
| 168 | + ok(r === nullDiag, 'diag:null row returns the SAME reference'); |
| 169 | + ok(r.diag === null, 'diag:null stays null (not replaced with {})'); |
| 170 | +} |
| 171 | +{ |
| 172 | + // non-object inputs are returned untouched (defensive) |
| 173 | + ok(vm.runInContext('trimRowDiag(null)', sandbox) === null, 'null row → null'); |
| 174 | + ok(vm.runInContext('trimRowDiag(undefined)', sandbox) === undefined, 'undefined row → undefined'); |
| 175 | +} |
| 176 | + |
| 177 | +console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`); |
| 178 | +if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`); |
| 179 | +console.log('='.repeat(60)); |
| 180 | +process.exit(out.fail ? 1 : 0); |
0 commit comments