feat(cli): opt-in native HTTPS/TLS for omniroute serve (#5242) (#5361)

Integrated into release/v3.8.41 (#5242/#5350 fixes). Tests green per PR.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 12:06:01 -03:00
committed by GitHub
parent e174e90f14
commit b7f6d3fd04
8 changed files with 312 additions and 7 deletions

View File

@@ -8,6 +8,10 @@
_In development — bullets added per PR; finalized at release._
### ✨ New Features
- **cli (serve):** opt-in native **HTTPS/TLS** for `omniroute serve` — so strict-CSP Electron apps and browsers can reach OmniRoute over `https://` instead of plain `http://localhost`. Provide `--tls-cert <path> --tls-key <path>` (or `OMNIROUTE_TLS_CERT`/`OMNIROUTE_TLS_KEY`) and the standalone server terminates TLS on the same listener (no extra port/proxy); WebSocket upgrade (live dashboard + `/v1` streaming) works over `wss://` unchanged since `https.Server extends http.Server`. With no TLS flags the HTTP path is byte-identical to before; only one of cert/key, or an unreadable path, logs a warning and stays HTTP (never half-enables, never crashes). Auto-generated self-signed certs for localhost are a follow-up; for now provide an explicit cert/key (or front OmniRoute with a TLS terminator). Regression guard: `tests/unit/tls-options.test.ts`. (Bug 1C of [#5242](https://github.com/diegosouzapw/OmniRoute/issues/5242) — thanks @jonlwheat2-gif)
### 🔧 Bug Fixes
- **grok-web:** forward the Cloudflare clearance cookies and stop mislabeling IP-reputation blocks as a bad cookie. "Check cookie" returned `Invalid SSO cookie` even with a valid, complete browser session — but the cookie parser was never the problem (it robustly extracts `sso`/`sso-rw` from a full DevTools header). Two real gaps fixed: **(1)** `buildGrokCookieHeader` now forwards `cf_clearance` and `__cf_bm` when pasted (it dropped them before; AIClient2API forwards them too) — strictly additive, a bare `sso` blob still yields exactly `sso=…`; **(2)** when the user supplied a `cf_clearance`, a 401 / invalid-credentials-403 from grok.com is now surfaced as an IP-reputation/anti-bot block (cf_clearance is IP+TLS+UA-pinned and can't be replayed from a different machine) instead of the misleading "Invalid SSO cookie — re-paste". A bare cookie with no clearance still gets the re-paste hint. Regression guards in `web-cookie-auth.test.ts` + `provider-validation-specialty.test.ts`. ([#5350](https://github.com/diegosouzapw/OmniRoute/issues/5350) — thanks @SeaXen)

View File

@@ -13,8 +13,13 @@ import {
buildServerNodeOptions,
buildNodeHeapArgs,
} from "../../../scripts/build/runtime-env.mjs";
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
// URL scheme for the "OmniRoute is running" banner — flipped to https when
// opt-in TLS (#5242) is active. Process-scoped: one `serve` run = one scheme.
let urlScheme = "http";
const ROOT = join(__dirname, "..", "..", "..");
// The standalone bundle ships in `dist/` (since the build-output-isolation
// refactor). Fall back to the legacy `app/` location so an upgrade over a
@@ -41,6 +46,14 @@ export function registerServe(program) {
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
.option(
"--tls-cert <path>",
t("serve.tls_cert") || "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)"
)
.option(
"--tls-key <path>",
t("serve.tls_key") || "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
)
.action(async (opts) => {
await runServe(opts);
});
@@ -140,6 +153,11 @@ export async function runServe(opts = {}) {
calibrateHeapFallbackMb(totalmem())
);
// #5242: opt-in native HTTPS. CLI flags take precedence over env; the child
// server (server-ws.mjs) reads these and terminates TLS on the same listener.
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
const env = {
...process.env,
OMNIROUTE_PORT: String(port),
@@ -152,8 +170,15 @@ export async function runServe(opts = {}) {
// `--max-old-space-size=…`) instead of clobbering it with the calibrated
// default — mirror the Electron/standalone launchers.
NODE_OPTIONS: buildServerNodeOptions(process.env, memoryLimit),
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
};
// Validate the TLS pair up front so the operator sees a clear warning in the
// CLI (the child re-validates authoritatively). Drives the banner scheme;
// when null we fall through to identical plain-HTTP behavior as before.
urlScheme = resolveTlsOptions(env) ? "https" : "http";
const isDaemon = opts.daemon === true;
const useTray = opts.tray === true;
@@ -190,8 +215,8 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
writePidFile("server", server.pid);
server.unref();
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
console.log(` \x1b[1mDashboard:\x1b[0m http://localhost:${dashboardPort}`);
console.log(` \x1b[1mAPI Base:\x1b[0m http://localhost:${apiPort}/v1`);
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${dashboardPort}`);
console.log(` \x1b[1mAPI Base:\x1b[0m ${urlScheme}://localhost:${apiPort}/v1`);
}
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen) {
@@ -321,7 +346,7 @@ async function maybeStartTray(port, apiPort, supervisor) {
const { initTray, isTraySupported } = await import("../tray/index.mjs");
if (!isTraySupported()) return;
const { default: open } = await import("open").catch(() => ({ default: null }));
const dashboardUrl = `http://localhost:${port}`;
const dashboardUrl = `${urlScheme}://localhost:${port}`;
const tray = await initTray({
port,
onQuit: () => {
@@ -348,8 +373,8 @@ async function maybeStartTray(port, apiPort, supervisor) {
}
async function onReady(dashboardPort, apiPort, noOpen) {
const dashboardUrl = `http://localhost:${dashboardPort}`;
const apiUrl = `http://localhost:${apiPort}`;
const dashboardUrl = `${urlScheme}://localhost:${dashboardPort}`;
const apiUrl = `${urlScheme}://localhost:${apiPort}`;
console.log(`
\x1b[32m✔ OmniRoute is running!\x1b[0m

View File

@@ -241,7 +241,9 @@
"no_recovery": "Disable auto-restart on crash (debugging mode)",
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
"tray": "Show system tray icon (desktop only, opt-in)",
"no_tray": "Disable system tray icon"
"no_tray": "Disable system tray icon",
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
},
"backup": {
"title": "Backup",

View File

@@ -29,6 +29,7 @@
"scripts/build/colocateOptionals.mjs",
"scripts/build/sync-env.mjs",
"scripts/dev/responses-ws-proxy.mjs",
"scripts/dev/tls-options.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/dev/sync-env.mjs",
"scripts/build/native-binary-compat.mjs",

View File

@@ -149,6 +149,12 @@ const EXTRA_MODULE_ENTRIES = [
src: ["scripts", "dev", "webdav-handler.mjs"],
dest: ["webdav-handler.mjs"],
},
{
// #5242: opt-in HTTPS/TLS resolver (server-ws.mjs dependency).
label: "tls-options (server-ws.mjs dependency)",
src: ["scripts", "dev", "tls-options.mjs"],
dest: ["tls-options.mjs"],
},
{
label: "runtime-env script",
src: ["scripts", "build", "runtime-env.mjs"],

View File

@@ -5,11 +5,23 @@ import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
import { maybeHandleWebdav } from "./webdav-handler.mjs";
import methodGuard from "./http-method-guard.cjs";
import { resolveTlsOptions, createServerListener } from "./tls-options.mjs";
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
const { wrapRequestListenerWithMethodGuard } = methodGuard;
// Opt-in native HTTPS (#5242). Resolved once at boot: when both OMNIROUTE_TLS_CERT
// and OMNIROUTE_TLS_KEY point at readable files we terminate TLS on the same
// listener Next binds to (so WS `upgrade` / request wrappers keep working over
// TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before.
const tlsOptions = resolveTlsOptions(process.env);
if (tlsOptions) {
console.log(
`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`
);
}
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret proving the trusted peer-IP stamp came from this server.
ensurePeerStampToken();
@@ -107,7 +119,10 @@ http.createServer = function createServerWithResponsesWs(...args) {
);
}
const server = originalCreateServer(...args);
// When TLS is configured, return an https.Server (terminating TLS on the same
// listener); otherwise the original http.Server. The downstream .on/.addListener
// patches below apply identically to both (https.Server extends http.Server).
const server = createServerListener(args, tlsOptions, { createHttp: originalCreateServer });
const originalOn = server.on.bind(server);
const originalAddListener = server.addListener.bind(server);

View File

@@ -0,0 +1,92 @@
// scripts/dev/tls-options.mjs
//
// Pure, dependency-light helpers for OmniRoute's opt-in native HTTPS/TLS serving
// (#5242, Bug 1C). Kept side-effect-free and free of heavy imports so it can be
// imported both by the CLI (bin/cli/commands/serve.mjs) and by the standalone
// server wrapper (standalone-server-ws.mjs), and unit-tested in isolation.
//
// TLS is strictly opt-in: when neither cert nor key is provided the server
// behaves EXACTLY as before (plain HTTP). A misconfiguration (only one of the
// pair, or an unreadable path) NEVER crashes the server — it logs a warning and
// falls back to HTTP, preserving today's behavior and loopback/security posture.
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
/**
* Resolve TLS options from environment variables.
*
* Reads `OMNIROUTE_TLS_CERT` and `OMNIROUTE_TLS_KEY` (filesystem paths). Returns
* `{ cert, key }` (file contents) only when BOTH are provided and readable.
* Otherwise returns `null` so the caller serves plain HTTP — never throwing.
*
* @param {NodeJS.ProcessEnv} [env=process.env]
* @param {{ readFileSync?: typeof fs.readFileSync, warn?: (msg: string) => void }} [deps]
* @returns {{ cert: Buffer|string, key: Buffer|string, certPath: string, keyPath: string } | null}
*/
export function resolveTlsOptions(
env = process.env,
{ readFileSync = fs.readFileSync, warn = (m) => console.warn(m) } = {}
) {
const certPath = typeof env?.OMNIROUTE_TLS_CERT === "string" ? env.OMNIROUTE_TLS_CERT.trim() : "";
const keyPath = typeof env?.OMNIROUTE_TLS_KEY === "string" ? env.OMNIROUTE_TLS_KEY.trim() : "";
// Neither provided → plain HTTP, no warning (the common, default case).
if (!certPath && !keyPath) return null;
// Only one of the pair → never half-enable TLS. Warn + fall back to HTTP.
if (!certPath || !keyPath) {
warn(
`[omniroute][tls] HTTPS not enabled: both OMNIROUTE_TLS_CERT and OMNIROUTE_TLS_KEY ` +
`are required (only ${certPath ? "cert" : "key"} provided). Serving HTTP.`
);
return null;
}
// Both provided → read them. A bad/unreadable path falls back to HTTP rather
// than crashing the server over a TLS misconfiguration.
try {
const cert = readFileSync(certPath);
const key = readFileSync(keyPath);
return { cert, key, certPath, keyPath };
} catch (err) {
warn(
`[omniroute][tls] HTTPS not enabled: could not read TLS cert/key ` +
`(${err?.code || err?.message || String(err)}). Serving HTTP.`
);
return null;
}
}
/**
* Create either an `https.Server` (when `tlsOptions` is provided) or an
* `http.Server` (when it is `null`), forwarding the same request-listener /
* options arguments the caller would otherwise pass to `http.createServer`.
*
* When TLS is enabled, any leading options object is merged with `cert`/`key`
* and the trailing request listener is preserved, so existing wiring (WebSocket
* `upgrade` handling, request wrappers) keeps working over TLS unchanged.
*
* @param {any[]} args - the args originally passed to http.createServer (options?, listener?)
* @param {{ cert: Buffer|string, key: Buffer|string } | null} tlsOptions
* @param {{ createHttp?: Function, createHttps?: Function }} [deps]
* @returns {import("node:http").Server | import("node:https").Server}
*/
export function createServerListener(
args,
tlsOptions,
{ createHttp = http.createServer.bind(http), createHttps = https.createServer.bind(https) } = {}
) {
const argList = Array.isArray(args) ? args : args === undefined ? [] : [args];
if (!tlsOptions) return createHttp(...argList);
const { cert, key } = tlsOptions;
const lastFnIdx = argList.map((a) => typeof a === "function").lastIndexOf(true);
const listener = lastFnIdx >= 0 ? argList[lastFnIdx] : undefined;
const baseOpts =
argList[0] && typeof argList[0] === "object" && !Buffer.isBuffer(argList[0]) ? argList[0] : {};
const merged = { ...baseOpts, cert, key };
return listener ? createHttps(merged, listener) : createHttps(merged);
}

View File

@@ -0,0 +1,160 @@
/**
* #5242 (Bug 1C) — opt-in native HTTPS/TLS serving for `omniroute serve`.
*
* `resolveTlsOptions` + `createServerListener` (scripts/dev/tls-options.mjs) are
* the pure decision helpers the CLI (serve.mjs) and the standalone server
* wrapper (standalone-server-ws.mjs) share. TLS is strictly opt-in:
* - both cert+key present & readable → TLS options → https.Server
* - neither present → null → http.Server (byte-identical to today)
* - only one of the pair → null + warning (never half-enable TLS)
* - unreadable path → null + warning (never crash over a TLS misconfig)
*
* Filesystem reads and the http/https factories are injected so the suite is
* deterministic and needs no real certs.
*/
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import https from "node:https";
const { resolveTlsOptions, createServerListener } = await import(
"../../scripts/dev/tls-options.mjs"
);
function makeReader(map: Record<string, string>) {
return (p: string) => {
if (p in map) return Buffer.from(map[p]);
const err: NodeJS.ErrnoException = new Error(`ENOENT: ${p}`);
err.code = "ENOENT";
throw err;
};
}
test("both cert+key provided and readable → returns TLS options", () => {
const warnings: string[] = [];
const opts = resolveTlsOptions(
{ OMNIROUTE_TLS_CERT: "/c/server.crt", OMNIROUTE_TLS_KEY: "/c/server.key" },
{ readFileSync: makeReader({ "/c/server.crt": "CERT", "/c/server.key": "KEY" }), warn: (m) => warnings.push(m) }
);
assert.ok(opts, "expected non-null TLS options");
assert.equal(opts.cert.toString(), "CERT");
assert.equal(opts.key.toString(), "KEY");
assert.equal(opts.certPath, "/c/server.crt");
assert.deepEqual(warnings, [], "no warning when correctly configured");
});
test("neither cert nor key → null, no warning (default HTTP path)", () => {
const warnings: string[] = [];
const opts = resolveTlsOptions({}, { warn: (m) => warnings.push(m) });
assert.equal(opts, null);
assert.deepEqual(warnings, []);
});
test("only cert provided → null + warning (never half-enable TLS)", () => {
const warnings: string[] = [];
const opts = resolveTlsOptions(
{ OMNIROUTE_TLS_CERT: "/c/server.crt" },
{ warn: (m) => warnings.push(m) }
);
assert.equal(opts, null);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /both OMNIROUTE_TLS_CERT and OMNIROUTE_TLS_KEY/);
});
test("only key provided → null + warning", () => {
const warnings: string[] = [];
const opts = resolveTlsOptions(
{ OMNIROUTE_TLS_KEY: "/c/server.key" },
{ warn: (m) => warnings.push(m) }
);
assert.equal(opts, null);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /both OMNIROUTE_TLS_CERT and OMNIROUTE_TLS_KEY/);
});
test("unreadable path → null + warning, falls back to HTTP (never crash)", () => {
const warnings: string[] = [];
const opts = resolveTlsOptions(
{ OMNIROUTE_TLS_CERT: "/missing.crt", OMNIROUTE_TLS_KEY: "/missing.key" },
{ readFileSync: makeReader({}), warn: (m) => warnings.push(m) }
);
assert.equal(opts, null);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /could not read TLS cert\/key/);
});
test("whitespace-only env values are treated as absent", () => {
const opts = resolveTlsOptions(
{ OMNIROUTE_TLS_CERT: " ", OMNIROUTE_TLS_KEY: " " },
{ warn: () => {} }
);
assert.equal(opts, null);
});
test("createServerListener: null tlsOptions → http server (unchanged)", () => {
let httpCalled = false;
let httpsCalled = false;
const listener = () => {};
const result = createServerListener([listener], null, {
createHttp: (...a: unknown[]) => {
httpCalled = true;
assert.equal(a[a.length - 1], listener);
return "HTTP_SERVER";
},
createHttps: () => {
httpsCalled = true;
return "HTTPS_SERVER";
},
});
assert.equal(result, "HTTP_SERVER");
assert.ok(httpCalled && !httpsCalled);
});
test("createServerListener: tlsOptions → https server with merged cert/key + listener", () => {
let httpCalled = false;
const listener = () => {};
const result = createServerListener([listener], { cert: "CERT", key: "KEY" }, {
createHttp: () => {
httpCalled = true;
return "HTTP_SERVER";
},
createHttps: (opts: { cert: string; key: string }, fn: unknown) => {
assert.equal(opts.cert, "CERT");
assert.equal(opts.key, "KEY");
assert.equal(fn, listener);
return "HTTPS_SERVER";
},
});
assert.equal(result, "HTTPS_SERVER");
assert.ok(!httpCalled);
});
test("createServerListener: merges a leading options object with cert/key", () => {
const listener = () => {};
createServerListener([{ keepAlive: true }, listener], { cert: "C", key: "K" }, {
createHttps: (opts: Record<string, unknown>, fn: unknown) => {
assert.equal(opts.keepAlive, true);
assert.equal(opts.cert, "C");
assert.equal(opts.key, "K");
assert.equal(fn, listener);
return "HTTPS_SERVER";
},
});
});
test("createServerListener: real default (no TLS) returns an http.Server", () => {
const server = createServerListener([], null);
assert.ok(server instanceof http.Server);
server.close();
});
test("createServerListener: with a real cert pair returns a real https.Server", async () => {
const { default: selfsigned } = await import("selfsigned");
const pems = selfsigned.generate([{ name: "commonName", value: "localhost" }], {
keySize: 2048,
algorithm: "sha256",
});
const server = createServerListener([() => {}], { cert: pems.cert, key: pems.private });
assert.ok(server instanceof https.Server, "expected a real https.Server instance");
server.close();
});