From a4f5cc9172ccd76c478307ba71a87226292d8b06 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 6 Jul 2026 22:16:30 +0800 Subject: [PATCH] Simplify Docker networking and external URL handling --- docker-compose.yml | 10 +-- docker/README.md | 35 +++++++---- packages/core/src/config/config.ts | 8 ++- packages/core/src/web/management-server.ts | 29 +++++++-- packages/ui/src/web-client-bridge.ts | 13 +++- tests/main/config-env-interpolation.test.mjs | 64 ++++++++++++++++++++ tests/main/web-management-server.test.mjs | 14 +++++ 7 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 tests/main/config-env-interpolation.test.mjs create mode 100644 tests/main/web-management-server.test.mjs diff --git a/docker-compose.yml b/docker-compose.yml index 8a8e7a8f..d4a09163 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,16 +4,8 @@ services: context: . dockerfile: Dockerfile image: claude-code-router:local - environment: - CCR_WEB_HOST: 127.0.0.1 - CCR_WEB_PORT: 3459 - CCR_NGINX_PORT: 8080 - CCR_GATEWAY_HOST: 127.0.0.1 - CCR_GATEWAY_PORT: 3456 - CCR_GATEWAY_CORE_PORT: 3457 - CCR_PUBLIC_HOST: 127.0.0.1 - CCR_PUBLIC_PORT: 3458 ports: + # Publish only Nginx. Internal web/gateway listeners stay inside the container. - "3458:8080" volumes: - ccr-data:/data diff --git a/docker/README.md b/docker/README.md index 09018363..c176bfdc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,11 +16,28 @@ Then open: - Web UI: - Gateway endpoint: +`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image +runs separate container-private listeners for management RPC, API gateway +routing, and the core gateway runtime. They are implementation details and are +not published or configured by the default Compose file. + +To use a different host port, change the Compose port mapping and keep the +public router endpoint in sync: + +```yaml +services: + ccr: + ports: + - "8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 +``` + The container stores config and SQLite databases under `/data`, backed by the `ccr-data` volume in `docker-compose.yml`. -On a fresh data volume, the Web UI starts immediately. The gateway port is -available through the same Nginx port, but the gateway only starts after at +On a fresh data volume, the Web UI starts immediately. The gateway endpoint is +available through the same Nginx entrypoint, but the gateway only starts after at least one provider and model are configured. ## Image scripts @@ -52,18 +69,14 @@ docker build --build-arg NODE_IMAGE=node:22-bookworm-slim -t claude-code-router: ## Environment +Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`, +and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not +`http://127.0.0.1:3458`. + | Variable | Default | Description | | --- | --- | --- | -| `CCR_WEB_HOST` | `127.0.0.1` | Internal core server bind host. | -| `CCR_WEB_PORT` | `3459` | Internal core server port. | -| `CCR_NGINX_PORT` | `8080` | Nginx port that serves UI and proxies management/gateway requests. | | `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. | -| `CCR_GATEWAY_HOST` | `127.0.0.1` | Internal gateway bind host. | -| `CCR_GATEWAY_PORT` | `3456` | Internal gateway port proxied by Nginx. | -| `CCR_GATEWAY_CORE_PORT` | `3457` | Internal core gateway port used for first-run config. | -| `CCR_PUBLIC_HOST` | `127.0.0.1` | Host used for the first-run public router endpoint. | -| `CCR_PUBLIC_PORT` | `3458` | Port used for the first-run public router endpoint. | -| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. | | `CCR_DATA_DIR` | `/data` | Container data root. | | `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. | | `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. | diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ed34829c..5e4e4bc7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -79,7 +79,7 @@ type LoadedAppConfig = Partial; }; -type RawAppConfigSource = "default" | "legacy-json" | "sqlite"; +export type RawAppConfigSource = "default" | "legacy-json" | "sqlite"; type RawAppConfigLoadResult = { source: RawAppConfigSource; @@ -211,7 +211,7 @@ export async function loadAppConfig(): Promise { try { const loadedRawConfig = await loadRawAppConfig(); const rawValue = loadedRawConfig.value; - const value = interpolateEnvVars(rawValue) as Partial; + const value = interpolateRawAppConfigEnvVars(rawValue, loadedRawConfig.source) as Partial; const picked = pickConfig(value); const providers = picked.Providers ?? DEFAULT_CONFIG.Providers; const port = picked.PORT ?? endpointPort(picked.routerEndpoint) ?? DEFAULT_CONFIG.PORT; @@ -2547,6 +2547,10 @@ function isDefaultSeedApiKey(apiKey: ApiKeyConfig): boolean { ); } +export function interpolateRawAppConfigEnvVars(value: unknown, source: RawAppConfigSource): unknown { + return source === "legacy-json" ? interpolateEnvVars(value) : value; +} + function interpolateEnvVars(value: unknown): unknown { if (typeof value === "string") { return value.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => { diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 92b4d8ff..966a0877 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -5,7 +5,6 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; import packageJson from "../../package.json"; import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store"; import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; @@ -1028,20 +1027,38 @@ async function revealFile(file: string): Promise { await execDetached("explorer.exe", ["/select,", file]); return; } - await openSystemExternal(pathToFileURL(path.dirname(file)).toString()); + await execDetached("xdg-open", [path.dirname(file)]); } export function openSystemExternal(target: string): Promise { - if (!target || target === "about:blank") { + const url = normalizeExternalHttpTarget(target); + if (!url) { return Promise.resolve(); } if (process.platform === "darwin") { - return execDetached("/usr/bin/open", [target]); + return execDetached("/usr/bin/open", [url]); } if (process.platform === "win32") { - return execDetached("cmd.exe", ["/d", "/s", "/c", "start", "", target]); + return execDetached("rundll32.exe", ["url.dll,FileProtocolHandler", url]); } - return execDetached("xdg-open", [target]); + return execDetached("xdg-open", [url]); +} + +export function normalizeExternalHttpTarget(target: unknown): string | undefined { + const trimmed = typeof target === "string" ? target.trim() : ""; + if (!trimmed || trimmed === "about:blank") { + return undefined; + } + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("External URL must be a valid absolute URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Only http and https URLs can be opened."); + } + return url.toString(); } function execDetached(command: string, args: string[]): Promise { diff --git a/packages/ui/src/web-client-bridge.ts b/packages/ui/src/web-client-bridge.ts index d9456f18..5e85df30 100644 --- a/packages/ui/src/web-client-bridge.ts +++ b/packages/ui/src/web-client-bridge.ts @@ -94,6 +94,14 @@ async function selectPluginDirectory(): Promise { return rpc("selectPluginDirectory", [directory.trim()]); } +function normalizeExternalHttpUrl(value: string): string { + const url = new URL(value.trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Only http and https URLs can be opened."); + } + return url.toString(); +} + const webClientBridge: CcrApi = { applyClaudeAppGateway: (config) => rpc("applyClaudeAppGateway", [config]) as ReturnType, applyProfile: () => rpc("applyProfile") as ReturnType, @@ -136,9 +144,8 @@ const webClientBridge: CcrApi = { onUpdateStatusChanged: noopSubscription, openBotGatewayQrWindow: (request) => rpc("openBotGatewayQrWindow", [request]) as ReturnType, openBuiltInBrowser: () => rpc("openBuiltInBrowser") as ReturnType, - openExternal: (url) => { - window.open(url, "_blank", "noopener,noreferrer"); - return Promise.resolve(); + openExternal: async (url) => { + window.open(normalizeExternalHttpUrl(url), "_blank", "noopener,noreferrer"); }, openProfile: (request) => rpc("openProfile", [request]) as ReturnType, probeProvider: (request) => rpc("probeProvider", [request]) as ReturnType, diff --git a/tests/main/config-env-interpolation.test.mjs b/tests/main/config-env-interpolation.test.mjs new file mode 100644 index 00000000..038cdf6c --- /dev/null +++ b/tests/main/config-env-interpolation.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { interpolateRawAppConfigEnvVars } from "../../packages/core/src/config/config.ts"; + +test("config env interpolation is limited to legacy JSON config", () => { + const previous = process.env.CCR_ENV_INTERPOLATION_SECRET; + process.env.CCR_ENV_INTERPOLATION_SECRET = "env-secret"; + + try { + const rawConfig = { + Providers: [ + { + account: { + connectors: [ + { + body: { + token: "${CCR_ENV_INTERPOLATION_SECRET}" + }, + endpoint: "https://usage.example.com/account", + headers: { + "x-env-secret": "$CCR_ENV_INTERPOLATION_SECRET" + }, + mapping: { + meters: [ + { + id: "balance", + kind: "balance", + remaining: "$.balance", + unit: "$CCR_ENV_INTERPOLATION_SECRET" + } + ] + }, + type: "http-json" + } + ], + enabled: true + }, + api_key: "${CCR_ENV_INTERPOLATION_SECRET}", + baseUrl: "https://api.example.com/v1", + models: ["model"], + name: "Remote" + } + ] + }; + + const sqliteConfig = interpolateRawAppConfigEnvVars(rawConfig, "sqlite"); + assert.equal(sqliteConfig.Providers[0].api_key, "${CCR_ENV_INTERPOLATION_SECRET}"); + assert.equal(sqliteConfig.Providers[0].account.connectors[0].headers["x-env-secret"], "$CCR_ENV_INTERPOLATION_SECRET"); + assert.equal(sqliteConfig.Providers[0].account.connectors[0].body.token, "${CCR_ENV_INTERPOLATION_SECRET}"); + assert.equal(sqliteConfig.Providers[0].account.connectors[0].mapping.meters[0].unit, "$CCR_ENV_INTERPOLATION_SECRET"); + + const legacyConfig = interpolateRawAppConfigEnvVars(rawConfig, "legacy-json"); + assert.equal(legacyConfig.Providers[0].api_key, "env-secret"); + assert.equal(legacyConfig.Providers[0].account.connectors[0].headers["x-env-secret"], "env-secret"); + assert.equal(legacyConfig.Providers[0].account.connectors[0].body.token, "env-secret"); + assert.equal(legacyConfig.Providers[0].account.connectors[0].mapping.meters[0].unit, "env-secret"); + } finally { + if (previous === undefined) { + delete process.env.CCR_ENV_INTERPOLATION_SECRET; + } else { + process.env.CCR_ENV_INTERPOLATION_SECRET = previous; + } + } +}); diff --git a/tests/main/web-management-server.test.mjs b/tests/main/web-management-server.test.mjs new file mode 100644 index 00000000..d37f3459 --- /dev/null +++ b/tests/main/web-management-server.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeExternalHttpTarget } from "../../packages/core/src/web/management-server.ts"; + +test("normalizeExternalHttpTarget only accepts absolute http and https URLs", () => { + assert.equal(normalizeExternalHttpTarget(""), undefined); + assert.equal(normalizeExternalHttpTarget(undefined), undefined); + assert.equal(normalizeExternalHttpTarget("about:blank"), undefined); + assert.equal(normalizeExternalHttpTarget(" https://example.com/path?q=1 "), "https://example.com/path?q=1"); + assert.equal(normalizeExternalHttpTarget("http://localhost:3458/"), "http://localhost:3458/"); + assert.throws(() => normalizeExternalHttpTarget("file:///etc/passwd"), /Only http and https/); + assert.throws(() => normalizeExternalHttpTarget("javascript:alert(1)"), /Only http and https/); + assert.throws(() => normalizeExternalHttpTarget("example.com"), /valid absolute URL/); +});