fix(dashboard): use lightweight ping endpoint for MaintenanceBanner (fixes #3040) (#3043)

Integrated into release/v3.8.8. Applied review fixes: moved the SELECT 1 into a pingDb() db helper (no raw SQL in route, Hard Rule #5) + the 503 catch no longer leaks err.message (Hard Rule #12). Thanks @herjarsa!
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-06-01 19:30:17 +02:00
committed by GitHub
parent 009ad13a91
commit fd26e601a2
1266 changed files with 153840 additions and 50869 deletions

View File

@@ -224,6 +224,26 @@ export async function syncStandaloneExtraModules(
sourcePath: path.join(rootDir, "scripts", "dev", "run-standalone.mjs"),
destRelative: path.join("dev", "run-standalone.mjs"),
},
{
// WS-aware wrapper that run-standalone.mjs prefers over bare server.js.
// It installs the trusted peer-IP stamp the authz middleware needs to
// allow loopback/LAN access to LOCAL_ONLY routes; without it the Docker
// container fails closed (every LOCAL_ONLY request 403s). Imports
// peer-stamp.mjs + responses-ws-proxy.mjs, so all three are co-located.
label: "WS/peer-stamp standalone server wrapper",
sourcePath: path.join(rootDir, "scripts", "dev", "standalone-server-ws.mjs"),
destRelative: "server-ws.mjs",
},
{
label: "peer-stamp helper (server-ws.mjs dependency)",
sourcePath: path.join(rootDir, "scripts", "dev", "peer-stamp.mjs"),
destRelative: "peer-stamp.mjs",
},
{
label: "responses-ws-proxy (server-ws.mjs dependency)",
sourcePath: path.join(rootDir, "scripts", "dev", "responses-ws-proxy.mjs"),
destRelative: "responses-ws-proxy.mjs",
},
{
label: "runtime-env script",
sourcePath: path.join(rootDir, "scripts", "build", "runtime-env.mjs"),

View File

@@ -32,6 +32,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
"docs/reference/openapi.yaml",
"open-sse/mcp-server/server.js",
"package.json",
"peer-stamp.mjs",
"responses-ws-proxy.mjs",
"scripts/dev/sync-env.mjs",
"server.js",
@@ -104,6 +105,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"app/server.js",
"app/server-ws.mjs",
"app/responses-ws-proxy.mjs",
"app/peer-stamp.mjs",
"bin/cli/program.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",

View File

@@ -202,13 +202,19 @@ cpSync(standaloneDir, APP_DIR, { recursive: true });
const standaloneWsSrc = join(ROOT, "scripts", "dev", "standalone-server-ws.mjs");
const responsesWsProxySrc = join(ROOT, "scripts", "dev", "responses-ws-proxy.mjs");
if (existsSync(standaloneWsSrc) && existsSync(responsesWsProxySrc)) {
const peerStampSrc = join(ROOT, "scripts", "dev", "peer-stamp.mjs");
if (existsSync(standaloneWsSrc) && existsSync(responsesWsProxySrc) && existsSync(peerStampSrc)) {
console.log(" 📋 Adding Responses WebSocket standalone wrapper...");
cpSync(standaloneWsSrc, join(APP_DIR, "server-ws.mjs"));
writeFileSync(
join(APP_DIR, "responses-ws-proxy.mjs"),
'export * from "../scripts/dev/responses-ws-proxy.mjs";\n'
);
// server-ws.mjs imports ./peer-stamp.mjs (the trusted peer-IP stamp helper
// the authz middleware relies on). It is self-contained (node builtins only),
// so copy it directly alongside server-ws.mjs. Without this the wrapper throws
// ERR_MODULE_NOT_FOUND on boot and the server falls back to no peer stamp.
cpSync(peerStampSrc, join(APP_DIR, "peer-stamp.mjs"));
}
// ── Next.js Turbopack Standalone Tracer Fix ───────────────

View File

@@ -5,6 +5,20 @@ export function parsePort(value, fallback) {
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
/**
* Resolve the V8 heap ceiling (MB) for the server process from
* `OMNIROUTE_MEMORY_MB`, mirroring `omniroute serve`. Clamped to [64, 16384];
* invalid/unset → fallback (512). The standalone launcher uses this so
* OMNIROUTE_MEMORY_MB can override the Docker image's NODE_OPTIONS fallback
* without clobbering any other runtime flags (#2939).
* @param {string | number | undefined | null} value
* @param {number} [fallback]
*/
export function resolveMaxOldSpaceMb(value, fallback = 512) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback;
}
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.

View File

@@ -11,9 +11,8 @@ fi
if [ -d "/app/data" ] && [ ! -w "/app/data" ]; then
echo "WARNING: /app/data is not writable by the current user (UID $(id -u))."
echo "Run this on the Docker host to fix:"
echo " sudo chown -R 1000:1000 ./data"
echo " sudo chown -R $(id -u):$(id -g) /app/data"
echo " chmod -R u+rwX ./data"
exit 1
fi
exec "$@"

View File

@@ -71,6 +71,8 @@ const IGNORE_FROM_CODE = new Set([
"NEXT_RUNTIME",
"NODE_TEST_CONTEXT",
"VITEST",
// Instruction snippet shown to users (Traffic Inspector HttpProxySnippetCard) — not OmniRoute config.
"NODE_TLS_REJECT_UNAUTHORIZED",
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",

View File

@@ -0,0 +1,53 @@
import { randomUUID } from "node:crypto";
/**
* Trusted peer-IP stamping for the custom Node HTTP servers.
*
* The Next.js middleware runtime (proxy.ts → runAuthzPipeline) exposes NO socket
* or peer IP — only request headers, ALL of which are client-controlled. The
* LOCAL_ONLY route guard (spawn-capable routes) must decide locality from the
* real TCP peer, never from the spoofable Host header.
*
* Our custom servers DO have the real `req.socket.remoteAddress`. They stamp it
* into PEER_IP_HEADER as `<token>|<ip>`, where <token> is a per-process secret
* (OMNIROUTE_PEER_STAMP_TOKEN). Any client-supplied value of PEER_IP_HEADER is
* deleted first, so a remote caller cannot pre-populate it. The middleware
* (src/server/authz/policies/management.ts → resolveStampedPeer) trusts the IP
* ONLY when the token matches this process's secret; otherwise it fails closed.
*
* Keep PEER_IP_HEADER in sync with PEER_IP_HEADER in
* src/server/authz/headers.ts (the TS side cannot import this .mjs).
*/
export const PEER_IP_HEADER = "x-omniroute-peer-ip";
/** Generate (once) and return the per-process stamp token, persisting it in env
* so the middleware running in the same process reads the identical value. */
export function ensurePeerStampToken() {
process.env.OMNIROUTE_PEER_STAMP_TOKEN ||= randomUUID();
return process.env.OMNIROUTE_PEER_STAMP_TOKEN;
}
/** Strip any client-supplied PEER_IP_HEADER and stamp the real TCP peer IP,
* token-prefixed. Never throws — a stamping failure must not block a request
* (it degrades to "locality unknown" → fail closed in the middleware). */
export function stampPeerIp(req) {
try {
if (!req || !req.headers) return;
// Node lowercases incoming header names; delete kills any client value.
delete req.headers[PEER_IP_HEADER];
const ip = req.socket && req.socket.remoteAddress;
if (ip) {
req.headers[PEER_IP_HEADER] = `${ensurePeerStampToken()}|${ip}`;
}
} catch {
/* never block a request on peer stamping */
}
}
/** Wrap a Node request listener so every request is peer-stamped first. */
export function wrapRequestListenerWithPeerStamp(listener) {
return function peerStampingRequestHandler(req, res) {
stampPeerIp(req);
return listener.call(this, req, res);
};
}

View File

@@ -8,6 +8,7 @@ import { bootstrapEnv } from "../build/bootstrap-env.mjs";
import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mjs";
import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
import { randomUUID } from "node:crypto";
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
@@ -49,6 +50,9 @@ const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0";
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK === "1";
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret used to prove the trusted peer-IP stamp came from this
// server (read by the authz middleware in the same process). See peer-stamp.mjs.
ensurePeerStampToken();
const nextApp = next({
dev,
@@ -71,7 +75,12 @@ async function start() {
baseUrl: `http://127.0.0.1:${dashboardPort}`,
});
const server = http.createServer((req, res) => requestHandler(req, res));
const server = http.createServer((req, res) => {
// Stamp the real TCP peer IP before Next sees the request, so the authz
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
stampPeerIp(req);
return requestHandler(req, res);
});
server.on("upgrade", async (req, socket, head) => {
try {
const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head);

View File

@@ -1,16 +1,34 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import {
resolveRuntimePorts,
withRuntimePortEnv,
resolveMaxOldSpaceMb,
spawnWithForwardedSignals,
} from "../build/runtime-env.mjs";
import { bootstrapEnv } from "../build/bootstrap-env.mjs";
const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
const childEnv = withRuntimePortEnv(env, runtimePorts);
spawnWithForwardedSignals("node", ["server.js"], {
// #2939: honor OMNIROUTE_MEMORY_MB (default 512), the same knob
// `omniroute serve` uses, so Docker users can control the server heap under
// load / large SQLite DBs. A trailing --max-old-space-size wins, so this
// overrides the image fallback without clobbering any other NODE_OPTIONS flags.
const maxOldSpaceMb = resolveMaxOldSpaceMb(childEnv.OMNIROUTE_MEMORY_MB);
childEnv.NODE_OPTIONS =
`${childEnv.NODE_OPTIONS || ""} --max-old-space-size=${maxOldSpaceMb}`.trim();
// Prefer the WS-aware wrapper (server-ws.mjs) over the bare Next standalone
// server.js: it installs the trusted peer-IP stamp (scripts/dev/peer-stamp.mjs)
// that the authz middleware needs to allow loopback/LAN access to LOCAL_ONLY
// routes. Falling back to server.js fails CLOSED (every LOCAL_ONLY request 403s)
// rather than trusting the spoofable Host header.
const entry = existsSync("server-ws.mjs") ? "server-ws.mjs" : "server.js";
spawnWithForwardedSignals("node", [entry], {
stdio: "inherit",
env: withRuntimePortEnv(env, runtimePorts),
env: childEnv,
});

View File

@@ -1,11 +1,14 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret proving the trusted peer-IP stamp came from this server.
ensurePeerStampToken();
function getPort(server) {
const address = server.address?.();
@@ -46,6 +49,13 @@ function wrapUpgradeListener(server, listener) {
}
http.createServer = function createServerWithResponsesWs(...args) {
// Next's standalone server.js may pass its request listener directly to
// createServer; wrap it so the real TCP peer IP is stamped before Next runs.
const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true);
if (lastFnIdx >= 0) {
args[lastFnIdx] = wrapRequestListenerWithPeerStamp(args[lastFnIdx]);
}
const server = originalCreateServer(...args);
const originalOn = server.on.bind(server);
const originalAddListener = server.addListener.bind(server);
@@ -54,6 +64,10 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "upgrade" && typeof listener === "function") {
return originalOn(eventName, wrapUpgradeListener(server, listener));
}
// …or it may attach the handler via server.on("request"): wrap that too.
if (eventName === "request" && typeof listener === "function") {
return originalOn(eventName, wrapRequestListenerWithPeerStamp(listener));
}
return originalOn(eventName, listener);
};
@@ -61,6 +75,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "upgrade" && typeof listener === "function") {
return originalAddListener(eventName, wrapUpgradeListener(server, listener));
}
if (eventName === "request" && typeof listener === "function") {
return originalAddListener(eventName, wrapRequestListenerWithPeerStamp(listener));
}
return originalAddListener(eventName, listener);
};

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* fill-missing-from-en.mjs — fills missing keys in all non-EN locale JSON files
* with the EN fallback value. Does NOT add translation markers (__MISSING__).
* Only fills keys that are absent — never overwrites existing translated values.
*
* Usage:
* node scripts/i18n/fill-missing-from-en.mjs
*
* Idempotent. Safe to run repeatedly.
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = new URL("../../src/i18n/messages/", import.meta.url).pathname;
const EN = JSON.parse(readFileSync(join(ROOT, "en.json"), "utf-8"));
function fillMissing(target, source) {
for (const k of Object.keys(source)) {
if (typeof source[k] === "object" && source[k] !== null && !Array.isArray(source[k])) {
target[k] = target[k] && typeof target[k] === "object" ? target[k] : {};
fillMissing(target[k], source[k]);
} else if (!(k in target)) {
target[k] = source[k]; // fallback EN value
}
}
}
let touched = 0;
for (const file of readdirSync(ROOT)) {
if (!file.endsWith(".json") || file === "en.json") continue;
const path = join(ROOT, file);
const data = JSON.parse(readFileSync(path, "utf-8"));
const before = JSON.stringify(data);
fillMissing(data, EN);
const after = JSON.stringify(data);
if (before !== after) {
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
touched++;
console.log(`[i18n] filled missing in ${file}`);
}
}
console.log(`[i18n] done — touched ${touched} locale files`);

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* generate-agent-skills.mjs — CLI wrapper for src/lib/agentSkills/generator.ts
*
* Usage:
* node scripts/skills/generate-agent-skills.mjs # dry-run (default)
* node scripts/skills/generate-agent-skills.mjs --apply # write SKILL.md files
* node scripts/skills/generate-agent-skills.mjs --prune # detect orphans (dry-run)
* node scripts/skills/generate-agent-skills.mjs --apply --prune # write + delete orphans
* node scripts/skills/generate-agent-skills.mjs --only=omni-providers,cli-serve
* node scripts/skills/generate-agent-skills.mjs --json # JSON output to stdout
*
* Exit codes:
* 0 — success (dry-run with no changes, or apply completed)
* 1 — error (import or generator threw)
* 2 — dry-run detected changes (use for CI fail-on-stale check)
*
* Security: no shell interpolation of user input (Hard Rule #13).
* All runtime values are passed as JS variables, not shell strings.
*/
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
import process from "node:process";
// ── Resolve project root from script location ─────────────────────────────────
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// scripts/skills/generate-agent-skills.mjs → up 2 levels → project root
const projectRoot = path.resolve(__dirname, "..", "..");
// ── Argument parsing ──────────────────────────────────────────────────────────
const args = process.argv.slice(2);
function hasFlag(flag) {
return args.some((a) => a === flag || a.startsWith(flag + "="));
}
function getFlagValue(flag) {
for (const arg of args) {
if (arg.startsWith(flag + "=")) return arg.slice(flag.length + 1);
}
return null;
}
const applyMode = hasFlag("--apply");
const pruneMode = hasFlag("--prune");
const jsonOutput = hasFlag("--json");
const onlyRaw = getFlagValue("--only");
const onlyIds = onlyRaw ? onlyRaw.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
// ── Utility: print table ──────────────────────────────────────────────────────
function printTable(report) {
const { generated, unchanged, pruned, orphansDetected, errors } = report;
console.log(`\nGenerated: ${generated.length} · Unchanged: ${unchanged.length} · Pruned: ${pruned.length} · Orphans: ${orphansDetected.length} · Errors: ${errors.length}\n`);
if (generated.length > 0) {
console.log(" GENERATED:");
for (const id of generated) {
console.log(` + ${id}`);
}
}
if (unchanged.length > 0 && unchanged.length <= 10) {
console.log(" UNCHANGED:");
for (const id of unchanged) {
console.log(` = ${id}`);
}
} else if (unchanged.length > 10) {
console.log(` UNCHANGED: ${unchanged.length} skills (all up-to-date)`);
}
if (orphansDetected.length > 0) {
console.log(" ORPHANS DETECTED:");
for (const id of orphansDetected) {
console.log(` ? ${id}`);
}
}
if (pruned.length > 0) {
console.log(" PRUNED:");
for (const id of pruned) {
console.log(` - ${id}`);
}
}
if (errors.length > 0) {
console.log(" ERRORS:");
for (const e of errors) {
console.log(` ! ${e.id}: ${e.error}`);
}
}
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main() {
// Change CWD to project root so generator resolves files correctly
process.chdir(projectRoot);
if (!jsonOutput) {
const mode = applyMode ? "apply" : "dry-run";
const prune = pruneMode ? " + prune" : "";
const filter = onlyIds ? ` (only: ${onlyIds.join(", ")})` : "";
console.log(`\nAgent Skills Generator [${mode}${prune}]${filter}`);
console.log("─".repeat(60));
}
// Dynamic import via tsx runtime — works because package.json has tsx devDep
// and node is invoked with --import tsx/esm by the caller (or we use tsx directly).
// To support plain `node` invocation, we use a dynamic import with tsx register.
let generateAgentSkills;
try {
// Try direct import first (when running under tsx or compiled)
const mod = await import("../../src/lib/agentSkills/generator.ts");
generateAgentSkills = mod.generateAgentSkills;
} catch (importErr) {
// Fallback: try tsx register approach
try {
const require = createRequire(import.meta.url);
// Register tsx for TypeScript support
const tsxPath = require.resolve("tsx/esm");
const { register } = await import("node:module");
register(tsxPath, import.meta.url);
const mod = await import("../../src/lib/agentSkills/generator.ts");
generateAgentSkills = mod.generateAgentSkills;
} catch (fallbackErr) {
console.error(
"Error: Could not import generator. Run with tsx:\n" +
" node --import tsx/esm scripts/skills/generate-agent-skills.mjs\n" +
`Import error: ${importErr instanceof Error ? importErr.message : String(importErr)}`,
);
process.exit(1);
}
}
let report;
try {
report = await generateAgentSkills({
dryRun: !applyMode,
prune: pruneMode,
outputDir: "skills",
onlyIds,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (jsonOutput) {
console.log(JSON.stringify({ error: msg }, null, 2));
} else {
console.error(`\nGenerator error: ${msg}`);
}
process.exit(1);
}
if (jsonOutput) {
console.log(JSON.stringify(report, null, 2));
} else {
printTable(report);
if (!applyMode) {
console.log(
"\n(dry-run) No files written. Use --apply to write SKILL.md files.\n",
);
} else {
console.log();
}
}
// Exit code 2 if dry-run detected pending changes (useful for CI)
if (!applyMode && report.generated.length > 0) {
process.exit(2);
}
process.exit(0);
}
main().catch((err) => {
console.error("Unexpected error:", err instanceof Error ? err.message : String(err));
process.exit(1);
});