import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { createDecipheriv, scryptSync } from "node:crypto"; import { fileURLToPath, pathToFileURL } from "node:url"; import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs"; import { printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs"; const STATIC_SALT = "omniroute-field-encryption-v1"; const KEY_LENGTH = 32; const CHECK_TIMEOUT_MS = 2000; function ok(name, message, details = {}) { return { name, status: "ok", message, details }; } function warn(name, message, details = {}) { return { name, status: "warn", message, details }; } function fail(name, message, details = {}) { return { name, status: "fail", message, details }; } function parsePort(value, fallback) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; } function parseConfiguredPort(value) { if (value === undefined || value === null || value === "") return { valid: true, port: null }; const parsed = Number.parseInt(String(value), 10); return { valid: Number.isFinite(parsed) && parsed > 0 && parsed <= 65535, port: parsed, }; } function formatBytes(bytes) { const gb = bytes / 1024 / 1024 / 1024; return `${gb.toFixed(1)} GB`; } function findEnvFileCandidates(dataDir) { const candidates = []; if (process.env.DATA_DIR) candidates.push(path.join(process.env.DATA_DIR, ".env")); candidates.push(path.join(dataDir, ".env")); candidates.push(path.join(process.cwd(), ".env")); return [...new Set(candidates)]; } function checkConfig(dataDir) { const envCandidates = findEnvFileCandidates(dataDir); const envFile = envCandidates.find((candidate) => fs.existsSync(candidate)); const portChecks = [ ["PORT", process.env.PORT], ["API_PORT", process.env.API_PORT], ["DASHBOARD_PORT", process.env.DASHBOARD_PORT], ].map(([name, value]) => ({ name, value, ...parseConfiguredPort(value) })); const invalidPorts = portChecks.filter((item) => !item.valid); if (invalidPorts.length > 0) { return fail( "Config", `Invalid port setting: ${invalidPorts.map((item) => item.name).join(", ")}`, { envFile: envFile || null, invalidPorts } ); } if (!envFile) { return warn("Config", ".env file not found; using defaults and process environment", { checked: envCandidates, }); } return ok("Config", `.env found at ${envFile}`, { envFile }); } function resolveMigrationsDir(rootDir) { const configured = process.env.OMNIROUTE_MIGRATIONS_DIR; const candidates = [ configured, path.join(rootDir, "src", "lib", "db", "migrations"), path.join(rootDir, "app", "src", "lib", "db", "migrations"), path.join(process.cwd(), "src", "lib", "db", "migrations"), ].filter(Boolean); return candidates.find((candidate) => fs.existsSync(candidate)) || null; } function readMigrationFiles(migrationsDir) { if (!migrationsDir) return []; return fs .readdirSync(migrationsDir) .filter((file) => /^\d+_.+\.sql$/.test(file)) .sort() .map((file) => { const [, version, name] = file.match(/^(\d+)_(.+)\.sql$/) || []; return { version, name, file }; }); } async function checkDatabase(dbPath, rootDir) { if (!fs.existsSync(dbPath)) { return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath }); } try { const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } = await readDatabaseHealth(dbPath); if (quickCheckValue !== "ok") { return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath }); } const migrationsDir = resolveMigrationsDir(rootDir); const migrationFiles = readMigrationFiles(migrationsDir); if (migrationFiles.length === 0) { return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" }); } if (!hasMigrationTable) { return warn("Database", "SQLite is readable, but migration table is missing", { dbPath }); } const applied = new Set(appliedMigrationVersions); const pending = migrationFiles.filter((migration) => !applied.has(migration.version)); if (pending.length > 0) { return warn("Database", `${pending.length} migration(s) appear pending`, { dbPath, pending: pending.map((migration) => migration.file), }); } return ok("Database", "SQLite quick_check passed and migrations look current", { dbPath }); } catch (error) { return fail("Database", "SQLite database could not be read", { dbPath, error: error instanceof Error ? error.message : String(error), }); } } function deriveStorageKey() { const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret) return null; return scryptSync(secret, STATIC_SALT, KEY_LENGTH); } function decryptCredentialSample(value, key) { const prefix = "enc:v1:"; const body = value.slice(prefix.length); const [ivHex, encryptedHex, authTagHex] = body.split(":"); if (!ivHex || !encryptedHex || !authTagHex) throw new Error("Malformed encrypted value"); const authTagBuf = Buffer.from(authTagHex, "hex"); const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"), { authTagLength: authTagBuf.length, }); decipher.setAuthTag(authTagBuf); let decrypted = decipher.update(encryptedHex, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } async function checkStorageEncryption(dbPath) { const secret = process.env.STORAGE_ENCRYPTION_KEY; if (secret !== undefined && String(secret).trim() === "") { return fail("Storage/encryption", "STORAGE_ENCRYPTION_KEY is set but empty"); } if (!fs.existsSync(dbPath)) { return secret ? ok("Storage/encryption", "Encryption key is configured; database not initialized yet") : warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode"); } try { const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath); if (!hasProviderTable) { return secret ? ok("Storage/encryption", "Encryption key is configured; provider table not initialized") : warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode"); } if (encryptedValues.length === 0) { return secret ? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found") : warn( "Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; credentials are plaintext" ); } if (!secret) { return fail( "Storage/encryption", "Encrypted credentials exist but STORAGE_ENCRYPTION_KEY is missing", { encryptedSamples: encryptedValues.length } ); } const key = deriveStorageKey(); for (const value of encryptedValues) { decryptCredentialSample(value, key); } return ok("Storage/encryption", "Encrypted credential samples decrypt successfully", { encryptedSamples: encryptedValues.length, }); } catch (error) { return fail("Storage/encryption", "Encrypted credential check failed", { error: error instanceof Error ? error.message : String(error), }); } } function checkPort(port, label) { return new Promise((resolve) => { const server = net.createServer(); server.once("error", (error) => { if (error.code === "EADDRINUSE") { resolve(warn("Port availability", `${label} port ${port} is already in use`, { port })); } else { resolve( warn("Port availability", `${label} port ${port} could not be checked`, { port, error: error.message, }) ); } }); server.once("listening", () => { server.close(() => { resolve(ok("Port availability", `${label} port ${port} is available`, { port })); }); }); server.listen(port, "127.0.0.1"); }); } async function checkPorts() { const port = parsePort(process.env.PORT || "20128", 20128); const apiPort = parsePort(process.env.API_PORT || String(port), port); const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port); const checks = await Promise.all([ checkPort(dashboardPort, "Dashboard"), apiPort === dashboardPort ? Promise.resolve(null) : checkPort(apiPort, "API"), ]); const results = checks.filter(Boolean); const failResult = results.find((result) => result.status === "fail"); if (failResult) return failResult; const warnResults = results.filter((result) => result.status === "warn"); if (warnResults.length > 0) { return warn("Port availability", warnResults.map((result) => result.message).join("; "), { ports: { apiPort, dashboardPort }, }); } return ok("Port availability", "Configured port(s) are available", { ports: { apiPort, dashboardPort }, }); } async function checkNodeRuntime(rootDir) { try { const { getNodeRuntimeSupport } = await import( pathToFileURL(path.join(rootDir, "bin", "nodeRuntimeSupport.mjs")).href ); const support = getNodeRuntimeSupport(); if (!support.nodeCompatible) { return fail("Node runtime", `${support.nodeVersion} is outside supported policy`, support); } return ok("Node runtime", `${support.nodeVersion} is supported`, support); } catch { // nodeRuntimeSupport.mjs is only available in full source installs, not in Docker images const version = process.version; return warn( "Node runtime", `${version} (runtime support module unavailable in this environment)`, { nodeVersion: version } ); } } async function checkNativeBinary(rootDir) { const candidates = [ path.join( rootDir, "app", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node" ), path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"), ]; const binaryPath = candidates.find((candidate) => fs.existsSync(candidate)); if (!binaryPath) { return warn("Native binary", "better-sqlite3 native binary was not found", { candidates }); } try { const { isNativeBinaryCompatible } = await import( pathToFileURL(path.join(rootDir, "scripts", "build", "native-binary-compat.mjs")).href ); const compatible = isNativeBinaryCompatible(binaryPath); if (!compatible) { return fail("Native binary", "better-sqlite3 native binary is incompatible", { binaryPath }); } return ok("Native binary", "better-sqlite3 native binary is compatible", { binaryPath }); } catch { // native-binary-compat.mjs is only available in full source installs, not in Docker images return warn("Native binary", "Compatibility check unavailable in this environment", { binaryPath, }); } } function checkMemory() { const configured = process.env.OMNIROUTE_MEMORY_MB || "512"; const memoryMb = Number.parseInt(configured, 10); if (!Number.isFinite(memoryMb) || memoryMb < 64 || memoryMb > 16384) { return fail("Memory", `Invalid OMNIROUTE_MEMORY_MB: ${configured}`, { configured }); } const total = os.totalmem(); const free = os.freemem(); const requestedBytes = memoryMb * 1024 * 1024; if (requestedBytes > total) { return warn( "Memory", `Requested memory ${memoryMb} MB exceeds total RAM ${formatBytes(total)}`, { memoryMb, totalBytes: total, freeBytes: free, } ); } return ok("Memory", `${memoryMb} MB limit configured; ${formatBytes(free)} free`, { memoryMb, totalBytes: total, freeBytes: free, }); } async function fetchWithTimeout(url) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timeout); } } function formatHostForUrl(host) { return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } function resolveLivenessUrl(options = {}) { const explicitUrl = options.livenessUrl || process.env.OMNIROUTE_DOCTOR_LIVENESS_URL; if (explicitUrl) return explicitUrl; const port = parsePort(process.env.PORT || "20128", 20128); const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port); const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1") .trim() .replace(/^https?:\/\//, "") .replace(/\/.*$/, ""); return `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/api/health/degradation`; } async function probeUrl(url) { try { const response = await fetchWithTimeout(url); return { ok: response.ok, status: response.status }; } catch { return { ok: false, status: 0 }; } } async function checkServerLiveness(options = {}) { const url = resolveLivenessUrl(options); // First attempt: configured health endpoint (may require auth token). const primary = await probeUrl(url); if (primary.ok) { return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status }); } // #6162: /api/health and /api/health/degradation require a management token. // When unauthenticated, fall back to probing a publicly served static asset // (favicon.ico) to confirm the Next.js server is alive and reachable. // Derive the fallback URL from the primary URL (preserving protocol/host/port) // so custom liveness URL configurations are honored. Fall back to defaults // only if the primary URL can't be parsed. let fallbackUrl; try { const parsed = new URL(url); parsed.pathname = "/favicon.ico"; parsed.search = ""; parsed.hash = ""; fallbackUrl = parsed.toString(); } catch { const port = parsePort(process.env.PORT || "20128", 20128); const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port); const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1") .trim() .replace(/^https?:\/\//, "") .replace(/\/.*$/, ""); fallbackUrl = `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/favicon.ico`; } const fallback = await probeUrl(fallbackUrl); if (fallback.ok) { return ok( "Server liveness", `Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`, { primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status } ); } return warn( "Server liveness", `Server health endpoint returned HTTP ${primary.status || "no-response"} and fallback probe failed`, { primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status } ); } export async function collectDoctorChecks(context = {}, options = {}) { const rootDir = context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const dataDir = resolveDataDir(); const dbPath = resolveStoragePath(dataDir); const checks = []; checks.push(checkConfig(dataDir)); checks.push(await checkDatabase(dbPath, rootDir)); checks.push(await checkStorageEncryption(dbPath)); checks.push(await checkPorts()); checks.push(await checkNodeRuntime(rootDir)); checks.push(await checkNativeBinary(rootDir)); checks.push(checkMemory()); if (!options.skipLiveness) { checks.push(await checkServerLiveness(options)); } // CLI tool health checks try { const { collectCliToolChecks } = await import("../../../src/lib/cli-helper/doctor/checks.ts"); const cliChecks = await collectCliToolChecks(); checks.push(...cliChecks); } catch (err) { checks.push(warn("CLI Tools", `Could not run CLI tool checks: ${err.message}`)); } return { dataDir, dbPath, checks, summary: { ok: checks.filter((check) => check.status === "ok").length, warn: checks.filter((check) => check.status === "warn").length, fail: checks.filter((check) => check.status === "fail").length, }, }; } function printCheck(check) { const label = check.status.toUpperCase().padEnd(4); const color = check.status === "ok" ? "\x1b[32m" : check.status === "warn" ? "\x1b[33m" : "\x1b[31m"; console.log(`${color}${label}\x1b[0m ${check.name}: ${check.message}`); } export function registerDoctor(program) { program .command("doctor") .description(t("doctor.title")) .option("--no-liveness", "Skip HTTP health endpoint probing") .option("--host ", "Host for server liveness probing", "127.0.0.1") .option("--liveness-url ", "Full health endpoint URL override") .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const exitCode = await runDoctorCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); } export async function runDoctorCommand(opts = {}, context = {}) { const isJson = (opts.output ?? "table") === "json"; const skipLiveness = !(opts.liveness ?? true); const result = await collectDoctorChecks(context, { skipLiveness, livenessHost: opts.host, livenessUrl: opts.livenessUrl, }); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { printHeading("OmniRoute Doctor"); console.log(`Data dir: ${result.dataDir}`); console.log(`Database: ${result.dbPath}\n`); for (const check of result.checks) { printCheck(check); } console.log( `\nSummary: ${result.summary.ok} ok, ${result.summary.warn} warning(s), ${result.summary.fail} failure(s)` ); } return result.summary.fail > 0 ? 1 : 0; }