const fs = require("fs"); const fsp = require("fs/promises"); const path = require("path"); const DEFAULT_TOKEN = "dbg_8f4e5c9b71a64f2fa3e7c1d6b5a9e2f0_owrx"; async function createPlugin(ctx) { return { async execute(action, input = {}) { if (action === "collectOwrxLogs") { return collectLogs(ctx, "owrx", input); } if (action === "getOwrxSnapshot") { return getSnapshot(ctx, "owrx"); } if (action === "clearOwrxLogs") { return clearLogs(ctx, "owrx"); } if (action === "collectUsbLogs") { return collectLogs(ctx, "usb", input); } if (action === "getUsbSnapshot") { return getSnapshot(ctx, "usb"); } if (action === "clearUsbLogs") { return clearLogs(ctx, "usb"); } if (action === "whichBinary") { return whichBinary(ctx, input); } throw new Error(`Unknown action: ${action}`); }, async getStatus() { const settings = resolveSettings(ctx); const owrxSnapshot = await readSnapshot(ctx, "owrx"); const usbSnapshot = await readSnapshot(ctx, "usb"); const whichSnapshot = await readSnapshot(ctx, "which"); return { enabled: settings.enabled, unitName: settings.unitName, collectLines: settings.collectLines, redactSensitive: settings.redactSensitive, tokenConfigured: Boolean(settings.remoteToken), owrxCollectedAt: owrxSnapshot && owrxSnapshot.collectedAt ? owrxSnapshot.collectedAt : null, owrxKeptLines: numberOrZero(owrxSnapshot && owrxSnapshot.keptLines), usbCollectedAt: usbSnapshot && usbSnapshot.collectedAt ? usbSnapshot.collectedAt : null, usbKeptLines: numberOrZero(usbSnapshot && usbSnapshot.keptLines), whichCollectedAt: whichSnapshot && whichSnapshot.collectedAt ? whichSnapshot.collectedAt : null }; }, async health() { return { ok: true }; } }; } async function collectLogs(ctx, scope, input) { const settings = resolveSettings(ctx); if (!settings.enabled) { return { ok: false, accepted: false, message: "Debug endpoint deaktiviert" }; } const lineCount = Number.isFinite(Number(input && input.lines)) ? clamp(Math.trunc(Number(input.lines)), 50, 4000) : settings.collectLines; const collection = scope === "usb" ? await collectUsb(ctx, lineCount) : await collectOwrx(ctx, settings.unitName, lineCount); const includePatterns = scope === "usb" ? settings.includePatternsUsb : settings.includePatterns; const keptLines = filterLines(collection.lines, includePatterns, settings.redactSensitive); const paths = resolvePaths(ctx, scope); await fsp.mkdir(paths.debugDir, { recursive: true }); const collectedAt = new Date().toISOString(); await fsp.writeFile(paths.logFilePath, keptLines.join("\n") + (keptLines.length ? "\n" : ""), "utf8"); const snapshot = { ok: true, scope, collectedAt, collectLines: lineCount, totalLines: collection.lines.length, keptLines: keptLines.length, unitName: scope === "owrx" ? settings.unitName : undefined, commands: collection.commands }; await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); ctx.emit("debug.remote.collected", { scope, totalLines: collection.lines.length, keptLines: keptLines.length }); return { ok: true, accepted: true, message: `Debug-Log gesammelt (${scope}, ${keptLines.length}/${collection.lines.length} Zeilen)`, scope, collectedAt, totalLines: collection.lines.length, keptLines: keptLines.length, outputPath: paths.logFilePath, snapshotPath: paths.snapshotFilePath }; } async function collectOwrx(ctx, unitName, lineCount) { const command = `journalctl -u ${unitName} -n ${lineCount} --no-pager -o short-iso`; const result = await ctx.commandRunner(command, { timeoutMs: 12000 }); const raw = String(result.stdout || result.stderr || ""); return { lines: raw.split(/\r?\n/).filter(Boolean), commands: [{ command, ok: Boolean(result.ok), code: result.code }] }; } async function collectUsb(ctx, lineCount) { const commands = [ "lsusb", "lsusb -t", `journalctl -k -n ${lineCount} --no-pager -o short-iso`, "dmesg -T | tail -n 300" ]; const lines = []; const meta = []; for (const command of commands) { const result = await ctx.commandRunner(command, { timeoutMs: 12000 }); const raw = String(result.stdout || result.stderr || "").trim(); lines.push(`[cmd] ${command}`); lines.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`); if (raw) { for (const line of raw.split(/\r?\n/)) { lines.push(line); } } lines.push(""); meta.push({ command, ok: Boolean(result.ok), code: result.code }); } return { lines: lines.filter((line, index, all) => line || (index > 0 && all[index - 1])), commands: meta }; } async function getSnapshot(ctx, scope) { const snapshot = await readSnapshot(ctx, scope); return { ok: true, accepted: true, message: "Debug-Snapshot geladen", scope, snapshot: snapshot || null }; } async function clearLogs(ctx, scope) { const paths = resolvePaths(ctx, scope); await fsp.mkdir(paths.debugDir, { recursive: true }); await fsp.writeFile(paths.logFilePath, "", "utf8"); const snapshot = { ok: true, scope, collectedAt: new Date().toISOString(), cleared: true, totalLines: 0, keptLines: 0 }; await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); ctx.emit("debug.remote.cleared", { scope, cleared: true }); return { ok: true, accepted: true, message: "Debug-Log geloescht", scope, snapshot }; } async function whichBinary(ctx, input) { const settings = resolveSettings(ctx); if (!settings.enabled) { return { ok: false, accepted: false, message: "Debug endpoint deaktiviert" }; } const name = String((input && (input.name || input.binary)) || "ffmpeg").trim().toLowerCase(); if (!/^[a-z0-9._+-]{1,64}$/.test(name)) { throw new Error("ungueltiger binary name"); } const found = await resolveBinaryPath(ctx, name); const paths = resolvePaths(ctx, "which"); await fsp.mkdir(paths.debugDir, { recursive: true }); const collectedAt = new Date().toISOString(); const line = `${collectedAt} ${name} => ${found.path || "NOT_FOUND"}`; await fsp.appendFile(paths.logFilePath, `${line}\n`, "utf8"); const snapshot = { ok: true, scope: "which", collectedAt, name, found: found.found, path: found.path || null }; await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); return { ok: true, accepted: true, scope: "which", name, found: found.found, path: found.path || null, outputPath: paths.logFilePath, snapshotPath: paths.snapshotFilePath }; } async function resolveBinaryPath(ctx, name) { if (process.platform === "win32") { const whereResult = await ctx.commandRunner(`where ${name}`, { timeoutMs: 4000 }); const pathLine = whereResult.ok ? firstLine(whereResult.stdout || "") : ""; return { found: Boolean(pathLine), path: pathLine || "" }; } const commandResult = await ctx.commandRunner(`command -v ${name}`, { timeoutMs: 4000 }); let pathLine = commandResult.ok ? firstLine(commandResult.stdout || "") : ""; if (!pathLine) { const whichResult = await ctx.commandRunner(`which ${name}`, { timeoutMs: 4000 }); pathLine = whichResult.ok ? firstLine(whichResult.stdout || "") : ""; } return { found: Boolean(pathLine), path: pathLine || "" }; } function firstLine(value) { const lines = String(value || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines.length > 0 ? lines[0] : ""; } function resolveSettings(ctx) { const collectLinesRaw = Number(ctx.getSetting("collectLines", 800)); const includePatternsRaw = String(ctx.getSetting("includePatterns", "")).trim(); const includePatternsUsbRaw = String(ctx.getSetting("includePatternsUsb", "")).trim(); return { enabled: ctx.getSetting("enabled", true) !== false, remoteToken: String(ctx.getSetting("remoteToken", DEFAULT_TOKEN)).trim(), collectLines: Number.isFinite(collectLinesRaw) ? clamp(Math.trunc(collectLinesRaw), 100, 4000) : 800, unitName: String(ctx.getSetting("unitName", "remotestation-arcg")).trim() || "remotestation-arcg", redactSensitive: ctx.getSetting("redactSensitive", true) !== false, includePatterns: includePatternsRaw ? includePatternsRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) : defaultOwrxPatterns(), includePatternsUsb: includePatternsUsbRaw ? includePatternsUsbRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) : [] }; } function resolvePaths(ctx, scope) { const safeScope = scope === "usb" ? "usb" : (scope === "which" ? "which" : "owrx"); const dataDirRaw = String(ctx.env.DATA_DIR || "").trim(); const dataDir = dataDirRaw ? (path.isAbsolute(dataDirRaw) ? dataDirRaw : path.resolve(ctx.rootDir, dataDirRaw)) : path.resolve(ctx.rootDir, "data"); const debugDir = path.join(dataDir, "debug"); return { debugDir, logFilePath: path.join(debugDir, `${safeScope}-debug.log`), snapshotFilePath: path.join(debugDir, `${safeScope}-debug-snapshot.json`) }; } async function readSnapshot(ctx, scope) { const paths = resolvePaths(ctx, scope); if (!fs.existsSync(paths.snapshotFilePath)) { return null; } try { const raw = await fsp.readFile(paths.snapshotFilePath, "utf8"); return JSON.parse(raw); } catch { return null; } } function filterLines(lines, includePatterns, redactSensitive) { const patterns = Array.isArray(includePatterns) ? includePatterns.map((entry) => String(entry || "").toLowerCase()).filter(Boolean) : []; const out = []; for (const line of lines) { const normalized = String(line || ""); const lower = normalized.toLowerCase(); if (patterns.length > 0 && !patterns.some((entry) => lower.includes(entry))) { continue; } out.push(redactSensitive ? redactLine(normalized) : normalized); } return out; } function redactLine(line) { let next = String(line || ""); next = next.replace(/(accessToken=)[^\s&]+/gi, "$1[redacted]"); next = next.replace(/(ticket=)[^\s&]+/gi, "$1[redacted]"); next = next.replace(/(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]+/gi, "$1[redacted]"); next = next.replace(/(eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.)[A-Za-z0-9_\-]+/g, "$1[redacted]"); return next; } function defaultOwrxPatterns() { return [ "openwebrx/plugin/state", "openwebrx/plugin/bands/select", "openwebrx/plugin/audio/connect", "openwebrx/rotor/status", "upstream timed out", "connection refused", "prematurely closed" ]; } function numberOrZero(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } module.exports = { createPlugin };