mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(docker): harden integrated service exposure
Squash merge PR #1291 into dev.
This commit is contained in:
+21
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user