mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
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:
committed by
GitHub
parent
5be3d81544
commit
425324ad5c
22
.env.example
22
.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
|
||||
#
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
126
tests/unit/live-ws-public-url.test.ts
Normal file
126
tests/unit/live-ws-public-url.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user