mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
172 lines
6.4 KiB
JavaScript
172 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Docker healthcheck script for OmniRoute.
|
|
* Probes the lightweight /healthz lifecycle endpoint on the dashboard port.
|
|
* Used by Dockerfile and docker-compose files.
|
|
*
|
|
* #10311 — the container HEALTHCHECK previously probed the heavy
|
|
* /api/monitoring/health path (synchronous SQLite reads + deep monitoring
|
|
* aggregation) on the same single-process event loop as catalog rebuild /
|
|
* long-context compression. Under load that probe could stall past the 5s
|
|
* timeout and flip the container `unhealthy`, restarting it mid-session and
|
|
* killing active SSE streams. /healthz is a pure in-memory lifecycle check
|
|
* with no DB access. Operators who want the deep monitoring probe can opt
|
|
* back in with OMNIROUTE_HEALTHCHECK_PATH.
|
|
*
|
|
* #3151 — in some Docker network setups the server binds to a container IP and
|
|
* a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice
|
|
* versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every
|
|
* error, so the container was reported `unhealthy` with an empty, undiagnosable
|
|
* `State.Health[].Output`. We now try an ordered list of hosts and surface the
|
|
* last error on total failure.
|
|
*
|
|
* Bridge Network Fix: Also probes the container's internal bridge IP (e.g., 172.17.0.2)
|
|
* to handle Docker network setups that isolate loopback interfaces.
|
|
*/
|
|
|
|
import { pathToFileURL } from "node:url";
|
|
import { networkInterfaces } from "node:os";
|
|
|
|
const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"];
|
|
const DEFAULT_TIMEOUT_MS = 4000;
|
|
const DEFAULT_HEALTH_PATH = "/healthz";
|
|
|
|
function normalizeBasePath(value) {
|
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
if (!trimmed || trimmed === "/") return "";
|
|
if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return "";
|
|
const segments = trimmed.split("/").filter(Boolean);
|
|
if (segments.some((segment) => segment === "." || segment === "..")) return "";
|
|
return `/${segments.join("/")}`;
|
|
}
|
|
|
|
/**
|
|
* Normalize an explicit health-check path override (OMNIROUTE_HEALTHCHECK_PATH).
|
|
* Returns "" when absent/invalid so callers fall back to DEFAULT_HEALTH_PATH.
|
|
* Mirrors normalizeBasePath's safety rules (no query/hash/backslash, no "." /
|
|
* ".." segments, must start with "/").
|
|
*/
|
|
function normalizeHealthPath(value) {
|
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
if (!trimmed) return "";
|
|
if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return "";
|
|
const segments = trimmed.split("/").filter(Boolean);
|
|
if (segments.some((segment) => segment === "." || segment === "..")) return "";
|
|
return `/${segments.join("/")}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve the health route to probe. By default the lightweight /healthz
|
|
* lifecycle endpoint (pure in-memory, no DB reads). An explicit
|
|
* OMNIROUTE_HEALTHCHECK_PATH override opts back into the deep monitoring
|
|
* probe. The configured Next.js basePath is always prefixed.
|
|
*
|
|
* @param {string} [basePathValue] value of OMNIROUTE_BASE_PATH
|
|
* @param {string} [healthPathValue] value of OMNIROUTE_HEALTHCHECK_PATH
|
|
*/
|
|
export function resolveHealthPath(basePathValue, healthPathValue) {
|
|
const basePath = normalizeBasePath(basePathValue);
|
|
const healthPath = normalizeHealthPath(healthPathValue) || DEFAULT_HEALTH_PATH;
|
|
return basePath ? `${basePath}${healthPath}` : healthPath;
|
|
}
|
|
|
|
/**
|
|
* Get the primary non-loopback IPv4 address (container internal IP).
|
|
* Falls back to null if unable to determine.
|
|
*/
|
|
function getContainerInternalIP() {
|
|
try {
|
|
const interfaces = networkInterfaces();
|
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
// Skip loopback and docker0, prioritize eth0/veth interfaces
|
|
if (name.startsWith("lo") || name === "docker0") continue;
|
|
const ipv4 = addrs?.find((a) => a.family === "IPv4" && !a.internal);
|
|
if (ipv4) return ipv4.address;
|
|
}
|
|
} catch {
|
|
// silently ignore if unable to read interfaces
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build the health URL for a host, bracketing IPv6 literals (e.g. `::1`).
|
|
* @param {string} host
|
|
* @param {string|number} port
|
|
* @param {string} healthPath path to probe, including any basePath prefix
|
|
*/
|
|
function healthUrl(host, port, healthPath = DEFAULT_HEALTH_PATH) {
|
|
const hostPart = host.includes(":") ? `[${host}]` : host;
|
|
return `http://${hostPart}:${port}${healthPath}`;
|
|
}
|
|
|
|
/**
|
|
* Probe the health endpoint across an ordered list of hosts. Resolves with the
|
|
* first host that returns a 2xx response; rejects with the last error if every
|
|
* host fails. Each attempt is bounded by a per-host timeout so one unreachable
|
|
* host cannot hang the whole probe.
|
|
*
|
|
* @param {object} opts
|
|
* @param {string|number} opts.port
|
|
* @param {string[]} [opts.hosts]
|
|
* @param {typeof fetch} [opts.fetchImpl]
|
|
* @param {number} [opts.timeoutMs]
|
|
* @param {string} [opts.healthPath]
|
|
* @returns {Promise<string>} the host that succeeded
|
|
*/
|
|
export async function probeHealth({
|
|
port,
|
|
hosts = DEFAULT_HOSTS,
|
|
fetchImpl = fetch,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
healthPath = DEFAULT_HEALTH_PATH,
|
|
} = {}) {
|
|
let lastError = new Error("no hosts to probe");
|
|
for (const host of hosts) {
|
|
try {
|
|
const res = await fetchImpl(healthUrl(host, port, healthPath), {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (res.ok) return host;
|
|
lastError = new Error(`${host}: HTTP ${res.status}`);
|
|
} catch (err) {
|
|
lastError = new Error(`${host}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async function main() {
|
|
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
|
|
|
// Build host list: defaults + detected container bridge IP
|
|
const hosts = [...DEFAULT_HOSTS];
|
|
const containerIP = getContainerInternalIP();
|
|
if (containerIP && !hosts.includes(containerIP)) {
|
|
hosts.push(containerIP);
|
|
}
|
|
|
|
try {
|
|
const healthPath = resolveHealthPath(
|
|
process.env.OMNIROUTE_BASE_PATH,
|
|
process.env.OMNIROUTE_HEALTHCHECK_PATH
|
|
);
|
|
await probeHealth({ port, hosts, healthPath });
|
|
process.exit(0);
|
|
} catch (err) {
|
|
// Surface the failure so `docker inspect ... .State.Health[].Output` is
|
|
// diagnostic instead of empty (#3151).
|
|
process.stderr.write(`healthcheck failed: ${err instanceof Error ? err.message : err}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Only auto-run when invoked as the entrypoint (so importing the helper in
|
|
// tests does not trigger a real probe + process.exit).
|
|
const isEntrypoint =
|
|
Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
if (isEntrypoint) {
|
|
main();
|
|
}
|