Files
2026-07-13 13:39:12 +08:00

528 lines
18 KiB
JavaScript

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>", "Host for server liveness probing", "127.0.0.1")
.option("--liveness-url <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;
}