Multi-Hoster-Upload/lib/diagnostics-collectors.js
Administrator d69e5c39bf feat(diagnostics): MCP gateway + harden redaction so no secret ever leaves the box
Adds the connect-by-code side of remote diagnostics and closes two real
secret-leak vectors that an end-to-end gateway<->agent test surfaced.

Gateway (gateway/, local stdio MCP, Claude connects once):
- 14 read-only tools (server_health hub, read_log, list_logs, list_errors,
  get_queue_state, get_history, get_config_redacted, get_system_info,
  get_rotation_state, get_app_events + connect/disconnect/list/current).
- The HOST is always supplied by the operator, never taken from the code.
- TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the
  token is sent (wss opt-in); plain ws is loopback-only.
- registry.json (holds bearer tokens) is gitignored; only an empty example ships.

Security hardening (gates every off-box payload):
- redactLogText now scrubs opaque bearer/token-family secrets that are NOT
  stored config credentials (e.g. a session token a hoster returns inside an
  error string): bare token/auth_token/refresh_token/session_token + standalone
  "Bearer <opaque>". Benign "token bucket" prose is left intact.
- get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so
  the cookie/sess line patterns can't gobble across a compact-JSON field) and
  drops the history subtree (served by get_history with its own per-error
  redaction). This plugs leaks via globalSettings.pendingQueue[].error etc.

Bind-address safety:
- _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the
  0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until
  encrypted transport (wss) exists — remote access goes through an SSH/VPN
  tunnel to loopback. (Never plaintext ws:// on all interfaces.)

Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/
get_config_redacted, asserts zero secret leakage, rejects doodstream log, path
traversal and write ops); + redaction regression tests in the main suite.
385 app tests + 9 gateway tests pass; lint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:39:31 +02:00

279 lines
10 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const READABLE_LOGS = {
debug: 'debug',
fileuploader: 'fileuploader',
accountRotation: 'accountRotation',
crash: 'crashLog'
};
const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
function createCollectors(deps) {
const { loadConfig, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() {
try { return support.collectSecretValues(loadConfig()); } catch { return []; }
}
function _scrub(value, secrets) {
try { return support.valueScrub(value, secrets || _secrets()); } catch { return value; }
}
function _deepRedact(value, secrets) {
const s = secrets || _secrets();
const walk = (v) => {
if (typeof v === 'string') return support.redactLogText(v, s);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = walk(v[k]);
return o;
}
return v;
};
try { return walk(value); } catch { return value; }
}
function _resolveLogPath(name, backup) {
const key = READABLE_LOGS[name];
if (!key) return null;
const paths = getAllLogPaths();
let p = paths[key];
if (!p) return null;
if (backup === 1 || backup === 2) p = `${p}.${backup}`;
return p;
}
function getSystemInfo() {
return { app: appInfo(), system: systemInfo(), agent: agentInfo() };
}
function getConfigRedacted(args) {
const section = (args && args.section) || 'all';
const cfg = loadConfig();
const secrets = support.collectSecretValues(cfg);
const sanitized = support.sanitizeConfig(cfg);
let pick;
let note;
if (section === 'all') {
pick = { ...sanitized };
delete pick.history;
note = 'history omitted from config — use get_history';
} else {
pick = sanitized[section] !== undefined ? sanitized[section] : null;
}
return { section, note, config: _deepRedact(pick, secrets) };
}
function listLogs() {
const paths = getAllLogPaths();
const dir = paths.logDir;
const files = [];
for (const [name, key] of Object.entries(READABLE_LOGS)) {
const base = paths[key];
if (!base) continue;
const variants = [];
for (const suffix of ['', '.1', '.2']) {
const fp = base + suffix;
try {
const st = fs.statSync(fp);
variants.push({ backup: suffix === '' ? 0 : Number(suffix.slice(1)), sizeBytes: st.size, mtime: st.mtime.toISOString() });
} catch {}
}
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
}
let siblings = [];
try {
siblings = fs.readdirSync(dir)
.filter(f => /\.log(\.\d+)?$/i.test(f))
.filter(f => !files.some(x => path.basename(x.path) === f || f.startsWith(path.basename(x.path))));
siblings = siblings.map(f => {
let size = 0, mtime = null;
try { const st = fs.statSync(path.join(dir, f)); size = st.size; mtime = st.mtime.toISOString(); } catch {}
return { name: f, readable: false, sizeBytes: size, mtime };
});
} catch {}
return { dir, files, otherLogs: siblings };
}
function readLog(args) {
const a = args || {};
const name = a.name;
const p = _resolveLogPath(name, a.backup);
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
const raw = support.collectFile(p, name, tailKb * 1024);
let content = support.redactLogText(raw, _secrets());
let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
let re;
try { re = new RegExp(a.grep, 'i'); } catch { re = null; }
if (re) {
const lines = content.split('\n').filter(l => re.test(l));
matchedLines = lines.length;
content = lines.join('\n');
}
}
let sizeBytes = null;
try { sizeBytes = fs.statSync(p).size; } catch {}
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
}
function getAppEvents(args) {
const limit = Math.min(Math.max(Number(args && args.limit) || 50, 1), 500);
const out = [];
const secrets = _secrets();
for (const name of ['crash', 'debug']) {
const p = _resolveLogPath(name);
if (!p) continue;
const raw = support.redactLogText(support.collectFile(p, name, 256 * 1024), secrets);
const lines = raw.split('\n').filter(l => l.trim() && !l.startsWith('==='));
for (const line of lines.slice(-limit)) out.push({ source: name, text: line });
}
return { events: out.slice(-limit), truncated: out.length > limit };
}
function _historyErrors(history, opts) {
const o = opts || {};
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
const secrets = _secrets();
const errors = [];
const byCategory = {};
for (const batch of (Array.isArray(history) ? history : [])) {
if (!batch || !Array.isArray(batch.files)) continue;
const ts = batch.timestamp ? Date.parse(batch.timestamp) : null;
if (sinceMs !== null && ts !== null && ts < sinceMs) continue;
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || r.status === 'done') continue;
const category = stats.classifyErrorCategory(r.error);
if (o.category && o.category !== category) continue;
if (o.hoster && o.hoster !== r.hoster) continue;
byCategory[category] = (byCategory[category] || 0) + 1;
errors.push({
ts: batch.timestamp || null,
fileName: file.name || file.fileName || '',
hoster: r.hoster || '',
accountId: r.accountId || undefined,
category,
error: support.redactLogText(String(r.error || ''), secrets)
});
}
}
}
return { errors, byCategory };
}
function listErrors(args) {
const a = args || {};
const cfg = loadConfig();
const { errors, byCategory } = _historyErrors(cfg.history, a);
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
}
function getQueueState(args) {
const a = args || {};
const cfg = loadConfig();
const pending = cfg.globalSettings && cfg.globalSettings.pendingQueue;
if (!pending || typeof pending !== 'object') {
return { source: 'empty', stale: false, counts: {}, selectedHosters: [] };
}
const counts = {};
for (const s of QUEUE_STATUSES) counts[s] = 0;
const jobs = Array.isArray(pending.queueJobs) ? pending.queueJobs : [];
for (const j of jobs) { if (counts[j.status] !== undefined) counts[j.status]++; }
const result = {
source: 'persisted',
stale: true,
savedAt: pending.savedAt || null,
selectedHosters: Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters : [],
fileCount: Array.isArray(pending.selectedFiles) ? pending.selectedFiles.length : 0,
counts
};
if (a.includeJobs !== false) {
const maxJobs = Math.min(Math.max(Number(a.maxJobs) || 200, 1), 2000);
result.jobs = _scrub(jobs.slice(0, maxJobs).map(j => ({
file: j.file, fileName: j.fileName, hoster: j.hoster, status: j.status, error: j.error || null
})));
result.jobsTruncated = jobs.length > maxJobs;
}
return result;
}
function getHistory(args) {
const a = args || {};
const cfg = loadConfig();
const history = Array.isArray(cfg.history) ? cfg.history : [];
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster(history);
const recent = [...history].slice(-limit).reverse();
const secrets = _secrets();
const batches = recent.map(b => {
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
if (a.includeFiles) {
out.files = (b.files || []).map(f => ({
name: f.name || f.fileName || '',
results: (f.results || []).map(r => {
const rr = { hoster: r.hoster, status: r.status };
if (r.error) rr.error = support.redactLogText(String(r.error), secrets);
if (a.includeUrls && r.url) rr.url = r.url;
return rr;
})
}));
}
return out;
});
return { totalBatches: history.length, returned: batches.length, perHoster, batches };
}
function getRotationState() {
const cfg = loadConfig();
return { rotationCursors: _scrub(cfg.rotationCursors || {}) };
}
function getHealth() {
const cfg = loadConfig();
const hosters = cfg.hosters && typeof cfg.hosters === 'object' ? Object.keys(cfg.hosters).filter(h => Array.isArray(cfg.hosters[h]) && cfg.hosters[h].length > 0) : [];
return {
reachabilityKnown: false,
hint: 'Live hoster probing (run_health_check) is disabled in this build. Configured hosters with at least one account are listed.',
configuredHosters: hosters
};
}
function serverHealth(args) {
const a = args || {};
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
const errors = listErrors(errArgs);
const queue = getQueueState({ includeJobs: false });
const history = getHistory({ limit: 5 });
const warnings = [];
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
return {
server: getSystemInfo(),
queue,
recentBatches: history.batches,
perHoster: history.perHoster,
errors,
hosters: getHealth(),
logs: listLogs(),
warnings
};
}
return {
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
READABLE_LOGS
};
}
module.exports = { createCollectors, READABLE_LOGS };