Simplify Docker networking and external URL handling

This commit is contained in:
musi 2026-07-06 22:16:30 +08:00
parent ad0d9db795
commit a4f5cc9172
7 changed files with 142 additions and 31 deletions

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View 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;
}
}
});

View 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/);
});