Skip to content

Commit 83cbbdc

Browse files
fix(events): trim admin SSE diag to the 4 fields the dashboard reads (drop credential blob)
The admin SSE stream (GET /api/events) forwarded full per-node result rows, whose diag blob carries internal/sensitive diagnostics — the V2Ray credential v2rayUUID, raw v2rayConfig/hsConfig, process v2rayStdout/ v2rayStderr, wgServerEndpoint, etc. The dashboard reads diag off a LIVE SSE row in exactly ONE place (the transport-detail renderer) and only reads v2rayProto/v2rayTransport/v2raySecurity/v2rayPort. Trim the diag on the three diag carriers of this stream (init results, live result events, state events' results[]) to those 4 fields via trimRowDiag(). Safe: the rich node-detail drawer + failure-report builder source diag from the persisted error-log over REST (er.raw_json), not the SSE row, so trimming the SSE row does not regress them. DB persistence keeps the full diag. Non-mutating: result rows are shared references with DB persistence (insertBatchResult -> raw_json) and in-memory state (getResults()), so the trim returns shallow clones with a fresh trimmed diag and never touches the originals. Rows without a diag pass through untouched. Adds test/admin-sse-diag-trim.test.js (extracts the real trimRowDiag, asserts the 4-key trim, sensitive-field drop, value carry-through, and non-mutation) and wires it into npm test.
1 parent c7e4287 commit 83cbbdc

3 files changed

Lines changed: 224 additions & 3 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

server.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2262,6 +2262,35 @@ app.get('/api/onchain-reports', rlOnchainReports, async (req, res) => {
22622262
}
22632263
});
22642264

2265+
// Trim a result row's `diag` blob down to the only 4 subfields the admin
2266+
// dashboard reads off a LIVE SSE row (the results-table transport-detail
2267+
// renderer: v2rayProto / v2rayTransport / v2raySecurity / v2rayPort). The full
2268+
// diag carries internal/sensitive diagnostics — the V2Ray credential
2269+
// `v2rayUUID`, raw `v2rayConfig` / `hsConfig`, process `v2rayStdout` /
2270+
// `v2rayStderr`, `wgServerEndpoint`, etc. — that never need to reach the
2271+
// browser. The rich node-detail drawer + failure-report builder source diag
2272+
// from the persisted error-log over REST (`er.raw_json`), NOT from the SSE row,
2273+
// so trimming the SSE row does not regress them.
2274+
//
2275+
// MUST be non-mutating: result rows are SHARED references with DB persistence
2276+
// (insertBatchResult → raw_json) and the in-memory state (getResults()). Return
2277+
// a shallow CLONE with a fresh trimmed diag; never touch the original row, its
2278+
// diag, or the getResults() arrays — mutating would strip the diag the
2279+
// drawer/persistence depend on. Rows without a diag (skips, wireguard-only)
2280+
// pass through untouched; we do NOT fabricate an empty diag key.
2281+
function trimRowDiag(r) {
2282+
if (!r || typeof r !== 'object' || !r.diag || typeof r.diag !== 'object') return r;
2283+
return {
2284+
...r,
2285+
diag: {
2286+
v2rayProto: r.diag.v2rayProto,
2287+
v2rayTransport: r.diag.v2rayTransport,
2288+
v2raySecurity: r.diag.v2raySecurity,
2289+
v2rayPort: r.diag.v2rayPort,
2290+
},
2291+
};
2292+
}
2293+
22652294
const rlAdminSse = sseLimit({ maxPerIp: 10, bucket: 'admin-sse' });
22662295
app.get('/api/events', adminOnly, rlAdminSse, (req, res) => {
22672296
res.setHeader('Content-Type', 'text/event-stream');
@@ -2274,15 +2303,27 @@ app.get('/api/events', adminOnly, rlAdminSse, (req, res) => {
22742303
// Strip wallet + balance internals AND runGranter (subscription granter
22752304
// address — operator-internal, never needs to leave the server).
22762305
const { walletAddress, balance, balanceUdvpn, spentUdvpn, runGranter, ...stateForSse } = state;
2277-
send({ type: 'init', state: stateForSse, results, logs: logBuffer.slice() });
2306+
// Trim each result row's diag to the 4 fields the dashboard reads (drop the
2307+
// credential/config/stdout blob). New array of clones — getResults() returns
2308+
// the shared in-memory state rows; never mutate them.
2309+
send({ type: 'init', state: stateForSse, results: results.map(trimRowDiag), logs: logBuffer.slice() });
22782310
const ADMIN_BLOCK = /^(loop:|iteration:|batch:)/;
22792311
const handler = (data) => {
22802312
if (data && typeof data.type === 'string' && ADMIN_BLOCK.test(data.type)) return;
22812313
// Strip runGranter from any state payload before sending — even the admin
22822314
// browser doesn't need the granter address; it's purely server-internal.
22832315
if (data && data.type === 'state' && data.state && typeof data.state === 'object') {
22842316
const { runGranter, ...safeState } = data.state;
2285-
send({ ...data, state: safeState });
2317+
// state events also carry results[] (broadcastStateFresh) — trim each
2318+
// row's diag here too. New array of clones; do not mutate data.results.
2319+
const safeResults = Array.isArray(data.results) ? data.results.map(trimRowDiag) : data.results;
2320+
send({ ...data, state: safeState, results: safeResults });
2321+
return;
2322+
}
2323+
// result events forward the same row object insertBatchResult persists to
2324+
// raw_json — clone+trim for the wire, leave data.result untouched.
2325+
if (data && data.type === 'result' && data.result && typeof data.result === 'object') {
2326+
send({ ...data, result: trimRowDiag(data.result) });
22862327
return;
22872328
}
22882329
send(data);

test/admin-sse-diag-trim.test.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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

Comments
 (0)