mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Simplify Docker networking and external URL handling
This commit is contained in:
parent
ad0d9db795
commit
a4f5cc9172
7 changed files with 142 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,11 +16,28 @@ Then open:
|
|||
- Web UI: <http://localhost:3458>
|
||||
- Gateway endpoint: <http://localhost:3458>
|
||||
|
||||
`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. |
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway"
|
|||
toolHub?: Partial<ToolHubConfig>;
|
||||
};
|
||||
|
||||
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<AppConfig> {
|
|||
try {
|
||||
const loadedRawConfig = await loadRawAppConfig();
|
||||
const rawValue = loadedRawConfig.value;
|
||||
const value = interpolateEnvVars(rawValue) as Partial<AppConfig>;
|
||||
const value = interpolateRawAppConfigEnvVars(rawValue, loadedRawConfig.source) as Partial<AppConfig>;
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
||||
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<void> {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ async function selectPluginDirectory(): Promise<unknown> {
|
|||
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<CcrApi["applyClaudeAppGateway"]>,
|
||||
applyProfile: () => rpc("applyProfile") as ReturnType<CcrApi["applyProfile"]>,
|
||||
|
|
@ -136,9 +144,8 @@ const webClientBridge: CcrApi = {
|
|||
onUpdateStatusChanged: noopSubscription,
|
||||
openBotGatewayQrWindow: (request) => rpc("openBotGatewayQrWindow", [request]) as ReturnType<CcrApi["openBotGatewayQrWindow"]>,
|
||||
openBuiltInBrowser: () => rpc("openBuiltInBrowser") as ReturnType<CcrApi["openBuiltInBrowser"]>,
|
||||
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<CcrApi["openProfile"]>,
|
||||
probeProvider: (request) => rpc("probeProvider", [request]) as ReturnType<CcrApi["probeProvider"]>,
|
||||
|
|
|
|||
64
tests/main/config-env-interpolation.test.mjs
Normal file
64
tests/main/config-env-interpolation.test.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
14
tests/main/web-management-server.test.mjs
Normal file
14
tests/main/web-management-server.test.mjs
Normal file
|
|
@ -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/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue