Add the reusable RMS core application (server, web UI, plugins, tests, tools) with generic defaults, GPL licensing, and maintainer context documentation so deployments can consume this repo as software source independent of station-specific overlays.
336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
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
|
|
};
|