diff --git a/.env.example b/.env.example index 65d2638e77..2206a5d566 100644 --- a/.env.example +++ b/.env.example @@ -92,8 +92,28 @@ PORT=20128 # Comma-separated extra origins allowed to open a live WebSocket. The # loopback dashboard origins are already permitted by default; use this # var when fronting the server with a domain (e.g. https://omni.local). +# ⚠️ When using NEXT_PUBLIC_LIVE_WS_PUBLIC_URL or exposing the WS server +# beyond loopback, this MUST include the public origin(s) — otherwise +# the Origin allow-list check will reject all browser connections. +# Example: LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com,https://ws.my-ai.com # LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com +# Comma-separated extra hostnames allowed to open a live WebSocket (LAN/Tailscale). +# Unlike LIVE_WS_ALLOWED_ORIGINS (which matches full origin URLs), this matches +# only the host portion — useful for wildcard-ish LAN/Tailscale setups. +# Used by: src/server/ws/liveServerAllowList.ts +# Example: LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50 +# LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50 + +# Public URL for the live dashboard WebSocket (client-side, browser only). +# Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel. +# The browser will connect to this URL instead of ws://hostname:20129. +# The /live-ws path is already proxied from the main app (port 20128) to the +# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs. +# Used by: src/hooks/useLiveDashboard.ts +# Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws +# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL= + # Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs. # Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle). # OMNIROUTE_DISABLE_LIVE_WS=0 @@ -804,7 +824,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # ── GitLab Duo ── # Register an OAuth app at: https://gitlab.com/-/profile/applications # Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback) -# Required scopes: api, read_user, openid, profile, email +# Required scopes: ai_features, read_user (matches GITLAB_DUO_CONFIG.scope in src/lib/oauth/constants/oauth.ts) # GITLAB_DUO_OAUTH_CLIENT_ID=*** # GITLAB_DUO_OAUTH_CLIENT_SECRET=*** # optional — PKCE flow does not require a secret # diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae7f2c279..ec1a7fed9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ ### ✨ New Features +- **dashboard (live WS behind reverse proxy):** the live dashboard WebSocket can now be fronted by a reverse proxy or Cloudflare Tunnel via `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` (e.g. `wss://ws.my-ai.com/live-ws`). The URL is honored both at build time (env inlined into the bundle) and at **runtime** for prebuilt Docker/npm images: the `/api/v1/ws?handshake=1` handshake now echoes a lazily-read `live.publicUrl` (only `ws://`/`wss://` values are accepted; anything else is rejected to `null`), and `useLiveDashboard` resolves the URL from that handshake before connecting, falling back to the previous `ws(s)://hostname:20129` default. Also documents `LIVE_WS_ALLOWED_HOSTS` and aligns the GitLab Duo OAuth scopes line in `.env.example` with the live config (`ai_features read_user`). Regression guard: `tests/unit/live-ws-public-url.test.ts` (5). ([#5877](https://github.com/diegosouzapw/OmniRoute/pull/5877) by [@ianriizky](https://github.com/ianriizky)) + - **providers (CLI profile auto-sync):** opt-in toggles to auto-regenerate CLI tool profiles after a provider model sync. When enabled, a model-catalog change (re)writes that tool's profile files from the live catalog — Codex (`~/.codex/*.config.toml`) and now **Claude Code** (`~/.claude/profiles//settings.json`, via an extracted `syncClaudeProfilesFromModels` + a new `claudeProfileAutoSync.ts` mirroring the Codex path). Both are **off by default** and never touch the active/default CLI config; they are backed by the `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES` / `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` feature flags (DB/dashboard override > env > default "false") and additionally gated behind the existing `CLI_ALLOW_CONFIG_WRITES` write-guard. A **"CLI profile auto-sync"** card on the CLI Code dashboard toggles each (moved from the providers dashboard in [#5778](https://github.com/diegosouzapw/OmniRoute/pull/5778) — thanks [@rdself](https://github.com/rdself)). Regression guards: `tests/unit/claude-profile-auto-sync-gate.test.ts`, `tests/unit/codex-profile-auto-sync-gate.test.ts`, `tests/unit/cli/setup-claude.test.ts` (follow-up to #5737). - **cli (startup banner):** the `serve` startup banner now prints the running OmniRoute version (`v3.8.x`) beneath the ASCII logo, so the active version is visible at a glance without a separate `--version` call. Regression guard: `tests/unit/cli-serve-version-banner.test.ts`. Thanks [@chirag127](https://github.com/chirag127) ([#5752](https://github.com/diegosouzapw/OmniRoute/pull/5752)). diff --git a/src/app/api/v1/ws/route.ts b/src/app/api/v1/ws/route.ts index 85876a77ac..bdd629578d 100644 --- a/src/app/api/v1/ws/route.ts +++ b/src/app/api/v1/ws/route.ts @@ -6,22 +6,36 @@ const WS_HANDSHAKE_HEADERS = { "Cache-Control": "no-store", }; -const WS_PROTOCOL = { - request: { - type: "request", - id: "req-1", - payload: { model: "openai/gpt-4.1-mini", messages: [] }, - }, - cancel: { type: "cancel", id: "req-1" }, - live: { - port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), - path: "/live", - protocol: "json", - channels: ["requests", "combo", "credentials"], - auth: "api-key", - heartbeatMs: 15000, - }, -}; +/** + * Public URL for the live dashboard WebSocket (reverse proxy / Cloudflare + * Tunnel setups). Read lazily at request time (not module load) so runtime + * env changes are honored, and only echoed when it is a ws:// or wss:// URL. + */ +function getLivePublicUrl(): string | null { + const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + if (!publicUrl) return null; + return publicUrl.startsWith("ws://") || publicUrl.startsWith("wss://") ? publicUrl : null; +} + +function getWsProtocol() { + return { + request: { + type: "request", + id: "req-1", + payload: { model: "openai/gpt-4.1-mini", messages: [] }, + }, + cancel: { type: "cancel", id: "req-1" }, + live: { + port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), + publicUrl: getLivePublicUrl(), + path: "/live", + protocol: "json", + channels: ["requests", "combo", "credentials"], + auth: "api-key", + heartbeatMs: 15000, + }, + }; +} export async function OPTIONS() { return new Response(null, { @@ -66,9 +80,10 @@ export async function GET(request: Request) { wsAuth: auth.wsAuth, authenticated: auth.authenticated, authType: auth.authType, - protocol: WS_PROTOCOL, + protocol: getWsProtocol(), live: { port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), + publicUrl: getLivePublicUrl(), path: "/live", protocol: "json", channels: ["requests", "combo", "credentials"], @@ -91,7 +106,7 @@ export async function GET(request: Request) { }, path: auth.wsPath, wsAuth: auth.wsAuth, - protocol: WS_PROTOCOL, + protocol: getWsProtocol(), }, { status: 426, diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index d5b5dae1bf..b31c4be047 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -17,7 +17,19 @@ import type { DashboardChannel, DashboardEventName } from "@/lib/events/types"; // ── Config ──────────────────────────────────────────────────────────────── const WS_RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000]; + +/** Only accept ws:// or wss:// URLs (mirrors the guard in src/app/api/v1/ws/route.ts). */ +function sanitizeWsPublicUrl(url: unknown): string | null { + if (typeof url !== "string" || url.length === 0) return null; + return url.startsWith("ws://") || url.startsWith("wss://") ? url : null; +} + +// Build-time inlined value (Docker/npm prebuilt images won't have this — the +// runtime value is discovered via the /api/v1/ws?handshake=1 handshake below). +const BUILD_TIME_PUBLIC_WS_URL = sanitizeWsPublicUrl(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); + function getDefaultWsUrl(): string { + if (BUILD_TIME_PUBLIC_WS_URL) return BUILD_TIME_PUBLIC_WS_URL; if (typeof window === "undefined") return "ws://localhost:20129"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const { hostname } = window.location; @@ -72,7 +84,7 @@ export interface UseLiveDashboardOptions { * Manages connection lifecycle, reconnection, and event streaming. */ export function useLiveDashboard({ - wsUrl = DEFAULT_WS_URL, + wsUrl, enabled = true, apiKey, channels = ["requests", "combo", "credentials"], @@ -86,6 +98,38 @@ export function useLiveDashboard({ reconnectAttempt: 0, }); + // Runtime discovery of the public WS URL via the handshake endpoint. + // NEXT_PUBLIC_* env vars are inlined at build time, so prebuilt Docker/npm + // images never see a runtime NEXT_PUBLIC_LIVE_WS_PUBLIC_URL — the server + // echoes it in the /api/v1/ws?handshake=1 response instead. + // Skipped when the caller passes an explicit wsUrl or the env was inlined. + const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined"; + const [handshakeUrl, setHandshakeUrl] = useState(null); + const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake); + + useEffect(() => { + if (!needsHandshake || wsUrlResolved) return; + let cancelled = false; + fetch("/api/v1/ws?handshake=1") + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (cancelled) return; + const publicUrl = sanitizeWsPublicUrl(body?.live?.publicUrl); + if (publicUrl) setHandshakeUrl(publicUrl); + }) + .catch(() => { + // Handshake unavailable — fall back to the default URL. + }) + .finally(() => { + if (!cancelled) setWsUrlResolved(true); + }); + return () => { + cancelled = true; + }; + }, [needsHandshake, wsUrlResolved]); + + const effectiveWsUrl = wsUrl ?? handshakeUrl ?? DEFAULT_WS_URL; + const [events, setEvents] = useState([]); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); @@ -108,7 +152,9 @@ export function useLiveDashboard({ })); try { - const wsUrlWithAuth = apiKey ? `${wsUrl}?token=${encodeURIComponent(apiKey)}` : wsUrl; + const wsUrlWithAuth = apiKey + ? `${effectiveWsUrl}?token=${encodeURIComponent(apiKey)}` + : effectiveWsUrl; const ws = new WebSocket(wsUrlWithAuth); wsRef.current = ws; @@ -206,7 +252,7 @@ export function useLiveDashboard({ error: err instanceof Error ? err.message : "Connection failed", })); } - }, [wsUrl, apiKey, channels.join(","), autoReconnect, connection.reconnectAttempt]); + }, [effectiveWsUrl, apiKey, channels.join(","), autoReconnect, connection.reconnectAttempt]); // Connect on mount and on reconnect trigger useEffect(() => { @@ -227,6 +273,10 @@ export function useLiveDashboard({ return; } + // Wait for the handshake URL resolution before opening the socket, so we + // never connect to the hardcoded default and then flap to the public URL. + if (!wsUrlResolved) return; + connect(); return () => { mountedRef.current = false; @@ -235,7 +285,7 @@ export function useLiveDashboard({ } wsRef.current?.close(); }; - }, [connect, enabled]); + }, [connect, enabled, wsUrlResolved]); // Connect (for manual retry) const reconnect = useCallback(() => { diff --git a/tests/unit/live-ws-public-url.test.ts b/tests/unit/live-ws-public-url.test.ts new file mode 100644 index 0000000000..3595e43370 --- /dev/null +++ b/tests/unit/live-ws-public-url.test.ts @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-live-ws-public-url-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +const ORIGINAL_PUBLIC_URL = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-live-ws-public-url-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const wsRoute = await import("../../src/app/api/v1/ws/route.ts"); + +function resetStorage() { + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + resetStorage(); + delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + await localDb.updateSettings({ + wsAuth: false, + requireLogin: true, + password: "hashed-password", + }); +}); + +test.after(() => { + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + + if (ORIGINAL_API_KEY_SECRET === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + } + + if (ORIGINAL_PUBLIC_URL === undefined) { + delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + } else { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = ORIGINAL_PUBLIC_URL; + } +}); + +test("handshake response includes publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is set", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://ws.my-ai.com/live-ws"; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as any; + assert.equal(body.live.publicUrl, "wss://ws.my-ai.com/live-ws"); +}); + +test("handshake response includes null publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is unset", async () => { + delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as any; + assert.equal(body.live.publicUrl, null); +}); + +test("protocol.live.publicUrl reflects env set after module import (lazy read)", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://custom.example.com/ws"; + + const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws")); + + assert.equal(response.status, 426); + const body = (await response.json()) as any; + assert.equal(body.protocol.live.publicUrl, "wss://custom.example.com/ws"); +}); + +test("publicUrl with non-WebSocket scheme is rejected (null)", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "https://ws.my-ai.com/live-ws"; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as any; + assert.equal(body.live.publicUrl, null); + assert.equal(body.protocol.live.publicUrl, null); +}); + +test("publicUrl with ws:// scheme is accepted", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20129/live-ws"; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as any; + assert.equal(body.live.publicUrl, "ws://lan-host:20129/live-ws"); +});