From b7f6d3fd045b8695eef828d5fadebd3e39e5447e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:06:01 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + bin/cli/commands/serve.mjs | 35 +++++- bin/cli/locales/en.json | 4 +- package.json | 1 + scripts/build/assembleStandalone.mjs | 6 + scripts/dev/standalone-server-ws.mjs | 17 ++- scripts/dev/tls-options.mjs | 92 +++++++++++++++ tests/unit/tls-options.test.ts | 160 +++++++++++++++++++++++++++ 8 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 scripts/dev/tls-options.mjs create mode 100644 tests/unit/tls-options.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb89bab86..204bdb99d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --tls-key ` (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) diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index f64c8cdd0e..2260127a38 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -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 ", 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 ", + t("serve.tls_cert") || "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)" + ) + .option( + "--tls-key ", + 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 diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index a4cfff165c..9d436d1d79 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -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", diff --git a/package.json b/package.json index c4b7854f20..bb879518b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 7df83d1444..6ee88d58a7 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -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"], diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index bb8941d56e..890caee40f 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -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); diff --git a/scripts/dev/tls-options.mjs b/scripts/dev/tls-options.mjs new file mode 100644 index 0000000000..5eb439e133 --- /dev/null +++ b/scripts/dev/tls-options.mjs @@ -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); +} diff --git a/tests/unit/tls-options.test.ts b/tests/unit/tls-options.test.ts new file mode 100644 index 0000000000..873a1ab147 --- /dev/null +++ b/tests/unit/tls-options.test.ts @@ -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) { + 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, 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(); +});