feat: add NEXT_PUBLIC_LIVE_WS_PUBLIC_URL for custom domain WebSocket support (#5878)

* docs: add ai_features scope to GitLab Duo OAuth env setup instructions

* docs: add LIVE_WS_ALLOWED_HOSTS env var to example config for LAN/Tailscale setups

* feat: add web socket public URL for reverse proxy/Cloudflare Tunnel WebSocket setups

* fix(dashboard): resolve live WS public URL at runtime via handshake with scheme validation

- Read NEXT_PUBLIC_LIVE_WS_PUBLIC_URL lazily in /api/v1/ws (function, not
  module-level const) so runtime env changes are honored in prebuilt images.
- Only echo/consume publicUrl when it is a ws:// or wss:// URL (server and
  client guards); anything else is rejected to null.
- useLiveDashboard now fetches /api/v1/ws?handshake=1 before connecting and
  prefers: explicit wsUrl > build-time env > handshake publicUrl > default.
- Align GitLab Duo scopes line in .env.example with GITLAB_DUO_CONFIG.scope.
- Extend tests: lazy env read + scheme validation cases.
- CHANGELOG entry for 3.8.43.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: Septianata Rizky Pratama <ian.rizkypratama@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 01:16:51 -03:00
committed by GitHub
parent 5be3d81544
commit 425324ad5c
5 changed files with 236 additions and 23 deletions

View File

@@ -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
#

View File

@@ -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/<name>/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)).

View File

@@ -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,

View File

@@ -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<string | null>(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<WsEventPayload[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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(() => {

View File

@@ -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");
});