Lumi/src/services/production-diagnostics.js
2026-07-26 14:22:51 +02:00

334 lines
14 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { performance } = require("perf_hooks");
const { db } = require("./db");
const { dependencyIssues } = require("./dependency-manager");
const { createLogger, listLogs } = require("./logger");
const { getPlugins, scanPluginDirectories } = require("./plugins");
const { readRecoveryMarker } = require("./recovery-mode");
const { getSetting, setSetting } = require("./settings");
const {
isLoopbackAddress,
isTrustedProxyAddress,
requestProtocol
} = require("./proxy-security");
const { readUpdateState } = require("./update-repository");
const repoRoot = path.join(__dirname, "..", "..");
const packageJson = require(path.join(repoRoot, "package.json"));
const TOKEN_PREFIX = "lumi_diag_";
const MAX_REQUESTS_PER_MINUTE = 20;
const requestWindows = new Map();
const diagnosticsLog = createLogger("core:diagnostics", { category: "security" });
const CHECKS = Object.freeze({
system_health: "Runtime, database, dependency, disk-space, and recovery health.",
update_state: "Latest local update state, recovery marker, and snapshot summary.",
plugins: "Installed plugin manifests and registry versions without plugin data.",
recent_errors: "Recent warning/error records with secrets and local paths redacted.",
benchmark: "Bounded read-only database, plugin-scan, and serialization timing."
});
function diagnosticsAccessStatus() {
return {
enabled: getSetting("production_diagnostics_enabled", false) === true,
configured: Boolean(getSetting("production_diagnostics_key_hash", "")),
key_prefix: String(getSetting("production_diagnostics_key_prefix", "") || ""),
created_at: getSetting("production_diagnostics_key_created_at", null),
checks: Object.entries(CHECKS).map(([id, description]) => ({ id, description }))
};
}
function issueDiagnosticsAccessKey() {
const key = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`;
setSetting("production_diagnostics_key_hash", hashAccessKey(key));
setSetting("production_diagnostics_key_prefix", `${key.slice(0, TOKEN_PREFIX.length + 8)}`);
setSetting("production_diagnostics_key_created_at", new Date().toISOString());
setSetting("production_diagnostics_enabled", true);
diagnosticsLog.warn("Production diagnostics access key rotated", { key_prefix: `${key.slice(0, TOKEN_PREFIX.length + 8)}` }, { event: "access_rotated" });
return key;
}
function revokeDiagnosticsAccess() {
setSetting("production_diagnostics_enabled", false);
setSetting("production_diagnostics_key_hash", "");
setSetting("production_diagnostics_key_prefix", "");
setSetting("production_diagnostics_key_created_at", null);
requestWindows.clear();
diagnosticsLog.warn("Production diagnostics access revoked", null, { event: "access_revoked" });
}
function authenticateDiagnosticsRequest(req, now = Date.now()) {
if (!diagnosticsAccessStatus().enabled) return { allowed: false, status: 404, reason: "disabled" };
if (!isSecureDiagnosticRequest(req)) return { allowed: false, status: 404, reason: "secure_transport_required" };
const authorization = String(req.get?.("authorization") || "");
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
const candidate = match?.[1] || "";
const storedHash = String(getSetting("production_diagnostics_key_hash", "") || "");
if (!verifyAccessKey(candidate, storedHash)) return { allowed: false, status: 404, reason: "invalid_key" };
const fingerprint = hashAccessKey(candidate).slice(0, 20);
const rate = consumeRateLimit(fingerprint, now);
if (!rate.allowed) return { allowed: false, status: 429, reason: "rate_limited", retry_after_seconds: rate.retry_after_seconds };
return { allowed: true, fingerprint, remaining: rate.remaining };
}
function runDiagnosticCheck(checkId) {
const check = String(checkId || "").trim();
if (!Object.hasOwn(CHECKS, check)) throw new Error("Unknown diagnostic check.");
const started = performance.now();
let result;
if (check === "system_health") result = systemHealth();
else if (check === "update_state") result = updateState();
else if (check === "plugins") result = pluginInventory();
else if (check === "recent_errors") result = recentErrors();
else result = boundedBenchmark();
return redactDiagnosticValue({
schema_version: 1,
check,
generated_at: new Date().toISOString(),
duration_ms: round(performance.now() - started),
result
});
}
function auditDiagnosticRequest(values = {}) {
diagnosticsLog.log(values.ok === false ? "warn" : "info", "Production diagnostics request", {
request_id: values.request_id,
check: values.check,
ok: values.ok !== false,
key_fingerprint: values.fingerprint || null,
reason: values.reason || null,
duration_ms: values.duration_ms || null
}, { event: "diagnostic_request", requestId: values.request_id });
}
function recentDiagnosticAudit(limit = 30) {
return listLogs({ limit: 250 })
.filter((entry) => ["Production diagnostics request", "Production diagnostics access key rotated", "Production diagnostics access revoked"].includes(entry.message))
.slice(0, Math.max(1, Math.min(100, Number(limit) || 30)))
.map((entry) => redactDiagnosticValue(entry));
}
function systemHealth() {
let database = { ok: false };
try {
database = { ok: db.prepare("SELECT 1 AS ok").get()?.ok === 1 };
} catch (error) {
database = { ok: false, error: error.message };
}
let disk = null;
try {
if (typeof fs.statfsSync !== "function") throw new Error("Disk statistics are unavailable on this Node.js version.");
const stats = fs.statfsSync(repoRoot);
disk = {
available_bytes: Number(stats.bavail) * Number(stats.bsize),
total_bytes: Number(stats.blocks) * Number(stats.bsize)
};
} catch {
disk = { available: false };
}
const dependencies = dependencyIssues(repoRoot);
return {
core_version: packageJson.version,
node_version: process.version,
platform: process.platform,
architecture: process.arch,
uptime_seconds: Math.floor(process.uptime()),
memory: process.memoryUsage(),
database,
disk,
dependencies: {
ready: dependencies.filter((item) => !item.optional).length === 0,
issues: dependencies
},
recovery: summarizeRecovery(readRecoveryMarker())
};
}
function updateState() {
const state = readUpdateState();
const index = readJson(path.join(repoRoot, "data", "snapshots", "index.json"), []);
const snapshots = Array.isArray(index) ? index : [];
return {
core_version: packageJson.version,
last_update: {
status: state.last_update_status || null,
target_kind: state.last_target_kind || null,
target_id: state.last_target_id || null,
target_version: state.last_target_version || null,
stage: state.last_update_stage || null,
error: state.last_error || null,
updated_at: state.last_update_at || state.updated_at || null,
source_ref: state.branch || null,
commit: state.commit || null
},
recovery: summarizeRecovery(readRecoveryMarker()),
snapshots: {
available: snapshots.filter((entry) => entry.status === "available").length,
latest: snapshots
.filter((entry) => entry.status === "available")
.sort((left, right) => Number(right.createdAt) - Number(left.createdAt))
.slice(0, 10)
.map((entry) => ({
type: entry.type,
plugin_id: entry.pluginId || null,
from_version: entry.from_version || null,
to_version: entry.to_version || null,
created_at: entry.createdAt || null,
storage_bytes: entry.storage_bytes || null
}))
}
};
}
function pluginInventory() {
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
return scanPluginDirectories().map((plugin) => ({
id: plugin.id,
name: plugin.name,
manifest_version: plugin.version,
registry_version: registry.get(plugin.id)?.version || null,
enabled: Boolean(registry.get(plugin.id)?.enabled),
version_matches_registry: !registry.has(plugin.id) || registry.get(plugin.id)?.version === plugin.version
})).sort((left, right) => left.id.localeCompare(right.id));
}
function recentErrors() {
return listLogs({ limit: 50, levels: ["warn", "error"] }).map((entry) => ({
level: entry.level,
message: entry.message,
details: entry.details,
created_at: entry.created_at
}));
}
function boundedBenchmark() {
const databaseStarted = performance.now();
for (let index = 0; index < 100; index += 1) db.prepare("SELECT 1 AS ok").get();
const databaseMs = performance.now() - databaseStarted;
const pluginStarted = performance.now();
let pluginCount = 0;
for (let index = 0; index < 5; index += 1) pluginCount = scanPluginDirectories().length;
const pluginMs = performance.now() - pluginStarted;
const serializationStarted = performance.now();
const sample = Array.from({ length: 250 }, (_, index) => ({ index, status: "ok", version: packageJson.version }));
for (let index = 0; index < 20; index += 1) JSON.stringify(sample);
const serializationMs = performance.now() - serializationStarted;
return {
fixed_workload: true,
database_select_100_ms: round(databaseMs),
plugin_scan_5_ms: round(pluginMs),
plugin_count: pluginCount,
json_serialize_20_ms: round(serializationMs)
};
}
function redactDiagnosticValue(value, key = "", depth = 0) {
if (depth > 8) return "[truncated]";
if (/(?:token|secret|password|passwd|authorization|cookie|session|credential|private.?key|api.?key)/i.test(key)) return "[redacted]";
if (typeof value === "string") return redactString(value).slice(0, 4000);
if (typeof value === "bigint") return Number(value);
if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactDiagnosticValue(item, key, depth + 1));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).slice(0, 100).map(([childKey, child]) => [childKey, redactDiagnosticValue(child, childKey, depth + 1)]));
}
return value;
}
function redactString(value) {
return String(value)
.replace(/\blumi_diag_[A-Za-z0-9_-]+\b/g, "[diagnostics key]")
.replace(/(https?:\/\/)[^/@\s:]+:[^/@\s]+@/gi, "$1[credentials]@")
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[signed token]")
.replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]")
.replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]")
.replace(/([?&](?:token|secret|session|password|api_key|apikey|auth|code)=)[^&#\s]+/gi, "$1[redacted]")
.replace(/(?:[A-Za-z]:\\|\\\\)[^\s<>\"|?*]+/g, "[local path]")
.replace(/\/(?:home|Users|mnt|var|tmp|opt|root)\/[^\s)\]}>]+/g, "[local path]")
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
.replace(/[A-Za-z0-9+_=-]{40,}/g, (candidate) => /[A-Za-z]/.test(candidate) && /\d/.test(candidate) ? "[high-entropy value]" : candidate);
}
function hashAccessKey(value) {
return crypto.createHash("sha256").update(String(value || "")).digest("hex");
}
function verifyAccessKey(candidate, expectedHash) {
if (!String(candidate || "").startsWith(TOKEN_PREFIX) || !/^[a-f0-9]{64}$/i.test(String(expectedHash || ""))) return false;
const actual = Buffer.from(hashAccessKey(candidate), "hex");
const expected = Buffer.from(expectedHash, "hex");
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
}
function consumeRateLimit(fingerprint, now = Date.now()) {
const windowStart = now - (now % 60000);
const current = requestWindows.get(fingerprint);
const state = !current || current.window_start !== windowStart ? { window_start: windowStart, count: 0 } : current;
state.count += 1;
requestWindows.set(fingerprint, state);
if (requestWindows.size > 1000) {
for (const [key, value] of requestWindows.entries()) if (value.window_start < windowStart) requestWindows.delete(key);
}
return {
allowed: state.count <= MAX_REQUESTS_PER_MINUTE,
remaining: Math.max(0, MAX_REQUESTS_PER_MINUTE - state.count),
retry_after_seconds: Math.max(1, Math.ceil((windowStart + 60000 - now) / 1000))
};
}
function isSecureDiagnosticRequest(req) {
if (req.secure === true) return true;
const address = String(req.socket?.remoteAddress || req.ip || "");
if (isLoopbackAddress(address)) return true;
return diagnosticsRequestProtocol(req) === "https";
}
function diagnosticsRequestProtocol(req) {
return requestProtocol(req);
}
function isTrustedPrivateProxyAddress(value) {
return isTrustedProxyAddress(value);
}
function summarizeRecovery(marker) {
if (!marker) return { active: false };
return {
active: ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status),
status: marker.status || null,
target_kind: marker.target_kind || null,
target_id: marker.target_id || null,
from_version: marker.from_version || null,
to_version: marker.to_version || null,
error: marker.error || null,
updated_at: marker.updated_at || null
};
}
function readJson(filePath, fallback) {
try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return fallback; }
}
function round(value) {
return Math.round(Number(value) * 100) / 100;
}
module.exports = {
CHECKS,
MAX_REQUESTS_PER_MINUTE,
auditDiagnosticRequest,
authenticateDiagnosticsRequest,
consumeRateLimit,
diagnosticsAccessStatus,
diagnosticsRequestProtocol,
hashAccessKey,
issueDiagnosticsAccessKey,
isSecureDiagnosticRequest,
isTrustedPrivateProxyAddress,
recentDiagnosticAudit,
redactDiagnosticValue,
revokeDiagnosticsAccess,
runDiagnosticCheck,
verifyAccessKey
};