From 857dac8751a759483a03e05247d711e3992dddb1 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 19 May 2026 07:45:20 -0400 Subject: [PATCH] fix(docker): harden integrated service exposure Squash merge PR #1291 into dev. --- docker/README.md | 28 ++++++-- docker/docker-compose.integrated.yml | 4 +- docker/docker-compose.yml | 4 +- src/docker/docker-bootstrap.ts | 38 ++++++++++- src/docker/docker-executor.ts | 1 + .../unit/docker/docker-assets-bundled.test.ts | 15 ++++- tests/unit/docker/docker-bootstrap.test.ts | 65 +++++++++++++++++++ tests/unit/docker/docker-executor.test.ts | 2 + 8 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 tests/unit/docker/docker-bootstrap.test.ts diff --git a/docker/README.md b/docker/README.md index 0308f347..b508b141 100644 --- a/docker/README.md +++ b/docker/README.md @@ -79,7 +79,22 @@ The `ccs docker` flow uses the integrated assets in this directory: - `docker/supervisord.conf` - `docker/entrypoint-integrated.sh` -### Post-Deployment: Enable Dashboard Auth (Required for Remote Access) +### Network Binding and Dashboard Auth + +The integrated Docker stack publishes the dashboard and CLIProxy ports on `127.0.0.1` by default. This keeps the services reachable from the Docker host and SSH tunnels without exposing them on every host interface. + +For remote hosts, prefer an SSH tunnel: + +```bash +ssh -L 3000:localhost:3000 my-server +# Then open http://localhost:3000 in browser +``` + +Only bind publicly when you have enabled dashboard authentication and have intentionally placed the host behind trusted network controls: + +```bash +CCS_DOCKER_BIND_HOST=0.0.0.0 ccs docker up --host my-server +``` When accessing the dashboard from a different machine (not `localhost`), the API blocks requests with **403 Forbidden** unless authentication is configured. Without auth, the dashboard appears empty (no providers, no version). @@ -112,12 +127,9 @@ After configuring auth, restart the dashboard: docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dashboard ``` -If accessing from `localhost` only (e.g., via SSH tunnel), auth is not required: +### Docker CLIProxy Secrets -```bash -ssh -L 3000:localhost:3000 my-server -# Then open http://localhost:3000 in browser -``` +On first startup, the integrated container generates per-install CLIProxy API and management secrets when the config is missing custom values. If you have already configured `cliproxy.auth.api_key` or `cliproxy.auth.management_secret`, Docker preserves those custom values. ### Post-Deployment: Migrate Existing Auth Tokens @@ -498,10 +510,12 @@ lsof -i :3000 lsof -i :8317 # Use different ports -docker run -p 4000:3000 -p 9317:8317 ... +docker run -p 127.0.0.1:4000:3000 -p 127.0.0.1:9317:8317 ... # Or with compose CCS_DASHBOARD_PORT=4000 CCS_CLIPROXY_PORT=9317 docker-compose up -d +# Public bind is opt-in: +CCS_DOCKER_BIND_HOST=0.0.0.0 docker-compose up -d ``` ### Container Keeps Restarting diff --git a/docker/docker-compose.integrated.yml b/docker/docker-compose.integrated.yml index 1984d14a..5eaf3578 100644 --- a/docker/docker-compose.integrated.yml +++ b/docker/docker-compose.integrated.yml @@ -10,8 +10,8 @@ services: restart: unless-stopped init: true ports: - - "${CCS_DASHBOARD_PORT:-3000}:3000" - - "${CCS_CLIPROXY_PORT:-8317}:8317" + - "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_DASHBOARD_PORT:-3000}:3000" + - "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_CLIPROXY_PORT:-8317}:8317" environment: CCS_PORT: 3000 NODE_ENV: production diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1cd63701..4b79c296 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,8 +9,8 @@ services: restart: unless-stopped init: true ports: - - "${CCS_DASHBOARD_PORT:-3000}:3000" - - "${CCS_CLIPROXY_PORT:-8317}:8317" + - "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_DASHBOARD_PORT:-3000}:3000" + - "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_CLIPROXY_PORT:-8317}:8317" environment: CCS_PORT: 3000 CCS_DEBUG: "${CCS_DEBUG:-}" diff --git a/src/docker/docker-bootstrap.ts b/src/docker/docker-bootstrap.ts index 775ed64d..41475393 100644 --- a/src/docker/docker-bootstrap.ts +++ b/src/docker/docker-bootstrap.ts @@ -1,8 +1,11 @@ +import { randomBytes } from 'crypto'; import { spawn } from 'child_process'; -import { ensureCLIProxyBinary } from '../cliproxy/binary-manager'; +import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from '../cliproxy/binary-manager'; import { configExists, configNeedsRegeneration, + CCS_CONTROL_PANEL_SECRET, + CCS_INTERNAL_API_KEY, generateConfig, getCliproxyWritablePath, regenerateConfig, @@ -10,13 +13,42 @@ import { import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { getCliproxyConfigPath } from '../cliproxy/config/path-resolver'; import { registerSession, unregisterSession } from '../cliproxy/session-tracker'; -import { getInstalledCliproxyVersion } from '../cliproxy/binary-manager'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../config/config-loader-facade'; + +function generateDockerSecret(): string { + return randomBytes(32).toString('base64url'); +} + +export function ensureDockerCliproxyAuth(): boolean { + const config = loadOrCreateUnifiedConfig(); + const auth = config.cliproxy.auth; + const needsApiKey = !auth?.api_key || auth.api_key === CCS_INTERNAL_API_KEY; + const needsManagementSecret = + !auth?.management_secret || auth.management_secret === CCS_CONTROL_PANEL_SECRET; + + if (!needsApiKey && !needsManagementSecret) { + return false; + } + + mutateConfig((nextConfig) => { + nextConfig.cliproxy.auth ??= {}; + if (needsApiKey) { + nextConfig.cliproxy.auth.api_key = generateDockerSecret(); + } + if (needsManagementSecret) { + nextConfig.cliproxy.auth.management_secret = generateDockerSecret(); + } + }); + + return true; +} async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> { const binaryPath = await ensureCLIProxyBinary(false); + const authWasGenerated = ensureDockerCliproxyAuth(); const configPath = !configExists(CLIPROXY_DEFAULT_PORT) ? generateConfig('gemini', CLIPROXY_DEFAULT_PORT) - : configNeedsRegeneration() + : authWasGenerated || configNeedsRegeneration() ? regenerateConfig(CLIPROXY_DEFAULT_PORT) : getCliproxyConfigPath(); diff --git a/src/docker/docker-executor.ts b/src/docker/docker-executor.ts index 8936b1bb..8ea2753b 100644 --- a/src/docker/docker-executor.ts +++ b/src/docker/docker-executor.ts @@ -202,6 +202,7 @@ export class DockerExecutor { CCS_NPM_VERSION: this.getInstalledCcsVersion(), CCS_DASHBOARD_PORT: String(options.port), CCS_CLIPROXY_PORT: String(options.proxyPort), + CCS_DOCKER_BIND_HOST: process.env.CCS_DOCKER_BIND_HOST || '127.0.0.1', }, REMOTE_DOCKER_BUILD_TIMEOUT_MS ), diff --git a/tests/unit/docker/docker-assets-bundled.test.ts b/tests/unit/docker/docker-assets-bundled.test.ts index ec681518..8ca0cf3b 100644 --- a/tests/unit/docker/docker-assets-bundled.test.ts +++ b/tests/unit/docker/docker-assets-bundled.test.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { describe, expect, it } from 'bun:test'; import { getDockerAssetPaths } from '../../../src/docker/docker-assets'; @@ -18,4 +18,17 @@ describe('docker bundled assets', () => { expect(existsSync(assets.supervisordConfig)).toBe(true); expect(existsSync(assets.entrypoint)).toBe(true); }); + + it('binds integrated service ports to loopback by default', () => { + const compose = readFileSync(assets.composeFile, 'utf8'); + + expect(compose).toContain( + '"${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_DASHBOARD_PORT:-3000}:3000"' + ); + expect(compose).toContain( + '"${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_CLIPROXY_PORT:-8317}:8317"' + ); + expect(compose).not.toContain('"${CCS_DASHBOARD_PORT:-3000}:3000"'); + expect(compose).not.toContain('"${CCS_CLIPROXY_PORT:-8317}:8317"'); + }); }); diff --git a/tests/unit/docker/docker-bootstrap.test.ts b/tests/unit/docker/docker-bootstrap.test.ts new file mode 100644 index 00000000..369ba4b7 --- /dev/null +++ b/tests/unit/docker/docker-bootstrap.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { ensureDockerCliproxyAuth } from '../../../src/docker/docker-bootstrap'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../../src/config/config-loader-facade'; +import { + CCS_CONTROL_PANEL_SECRET, + CCS_INTERNAL_API_KEY, +} from '../../../src/cliproxy/config/config-generator'; + +const originalCcsHome = process.env.CCS_HOME; +const tempDirs: string[] = []; + +function useTempCcsHome(): string { + const dir = mkdtempSync(join(tmpdir(), 'ccs-docker-auth-')); + tempDirs.push(dir); + process.env.CCS_HOME = dir; + return dir; +} + +afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('docker bootstrap auth', () => { + it('generates per-install CLIProxy secrets when Docker config has only defaults', () => { + useTempCcsHome(); + + const changed = ensureDockerCliproxyAuth(); + const config = loadOrCreateUnifiedConfig(); + + expect(changed).toBe(true); + expect(config.cliproxy.auth?.api_key).toBeTruthy(); + expect(config.cliproxy.auth?.management_secret).toBeTruthy(); + expect(config.cliproxy.auth?.api_key).not.toBe(CCS_INTERNAL_API_KEY); + expect(config.cliproxy.auth?.management_secret).not.toBe(CCS_CONTROL_PANEL_SECRET); + expect(config.cliproxy.auth?.api_key).not.toBe(config.cliproxy.auth?.management_secret); + }); + + it('preserves custom CLIProxy auth values for Docker deployments', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { + api_key: 'custom-api-key', + management_secret: 'custom-management-secret', + }; + }); + + const changed = ensureDockerCliproxyAuth(); + const config = loadOrCreateUnifiedConfig(); + + expect(changed).toBe(false); + expect(config.cliproxy.auth?.api_key).toBe('custom-api-key'); + expect(config.cliproxy.auth?.management_secret).toBe('custom-management-secret'); + }); +}); diff --git a/tests/unit/docker/docker-executor.test.ts b/tests/unit/docker/docker-executor.test.ts index 3b9a5f2b..5cab02e3 100644 --- a/tests/unit/docker/docker-executor.test.ts +++ b/tests/unit/docker/docker-executor.test.ts @@ -53,6 +53,7 @@ describe('docker executor', () => { expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0'); expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000'); expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317'); + expect(calls[0].options?.env?.CCS_DOCKER_BIND_HOST).toBe('127.0.0.1'); expect(calls[0].options?.timeoutMs).toBe(300_000); }); @@ -89,6 +90,7 @@ describe('docker executor', () => { expect(calls[2].args[1]).toContain("export CCS_NPM_VERSION='7.59.0'"); expect(calls[2].args[1]).toContain("export CCS_DASHBOARD_PORT='3000'"); expect(calls[2].args[1]).toContain("export CCS_CLIPROXY_PORT='8317'"); + expect(calls[2].args[1]).toContain("export CCS_DOCKER_BIND_HOST='127.0.0.1'"); expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1'); expect(calls[2].options?.timeoutMs).toBe(300_000); });