fix: preserve Docker legacy API key during rotation

This commit is contained in:
Tam Nhu Tran
2026-05-22 16:49:16 -04:00
parent 6088ebaa09
commit 30971ebb28
22 changed files with 928 additions and 18 deletions
+12
View File
@@ -131,6 +131,18 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dash
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.
If you upgraded from an older Docker deployment that used the historical `ccs-internal-managed` API key, CCS keeps that legacy key valid beside the new per-install key for 14 days by default. During the grace period, every `ccs docker up` prints the new key and expiry date to stderr. Override the window with `CCS_DOCKER_LEGACY_KEY_GRACE_DAYS`.
```bash
ccs docker show-key # masked
ccs docker show-key --full # reveal the current key
ccs docker finalize-key-rotation
```
Run `finalize-key-rotation` after updating clients to remove the legacy key immediately.
If a previous upgrade already replaced the old key before this grace logic was available, run once with `CCS_DOCKER_RESTORE_LEGACY_API_KEY=1` to explicitly restore the temporary legacy-key grace window. CCS does not infer this from random-looking custom keys.
### Post-Deployment: Migrate Existing Auth Tokens
If you have existing CLIProxy OAuth tokens from a previous deployment, copy them into the Docker volume:
+3
View File
@@ -21,6 +21,9 @@ services:
# with the canonical ghcr.io/kaitranntt/ccs prefix.
image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
restart: unless-stopped
environment:
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"
CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"
ports:
- "3000:3000"
- "8317:8317"
+2
View File
@@ -17,6 +17,8 @@ services:
NODE_ENV: production
NO_COLOR: "${NO_COLOR:-}"
CCS_DEBUG: "${CCS_DEBUG:-}"
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"
CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"
volumes:
- ccs_home:/root/.ccs
- ccs_logs:/var/log/ccs
+7 -2
View File
@@ -15,6 +15,7 @@ import { getDeniedModelIdReasonForProvider } from '../ai-providers/model-id-norm
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation';
/** Internal API key for CCS-managed requests */
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
@@ -623,8 +624,12 @@ function generateUnifiedConfigContent(
const effectiveApiKey = getEffectiveApiKey();
const effectiveSecret = getEffectiveManagementSecret();
// Build api-keys section with internal key + preserved user keys
const allApiKeys = [effectiveApiKey, ...userApiKeys];
// Build api-keys section with internal key + preserved user keys.
// Docker upgrades may temporarily keep the historical default key as a
// compatibility grace key; the marker file controls when that is active.
const allApiKeys = Array.from(
new Set([effectiveApiKey, ...getActiveDockerLegacyApiKeys(), ...userApiKeys])
);
const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
// Unified config with enhanced CLIProxyAPI features
+10 -1
View File
@@ -281,7 +281,16 @@ export const CLIPROXY_SUBCOMMANDS = [
'resume',
] as const;
export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const;
export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const;
export const DOCKER_SUBCOMMANDS = [
'up',
'down',
'status',
'update',
'logs',
'config',
'show-key',
'finalize-key-rotation',
] as const;
export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const;
export const TOKENS_FLAGS = [
'--show',
+2
View File
@@ -220,6 +220,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
return completeSubcommands([], ['--port', '--proxy-port', '--host', '--help', '-h']);
if (subcommand === 'logs')
return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']);
if (subcommand === 'show-key')
return completeSubcommands([], ['--full', '--host', '--help', '-h']);
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
case 'browser':
if (!subcommand || subcommand.startsWith('-')) {
@@ -0,0 +1,60 @@
import * as fs from 'fs';
import { DockerExecutor } from '../../docker';
import { configExists, regenerateConfig } from '../../cliproxy/config/config-generator';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import { finalizeDockerKeyRotation } from '../../docker/docker-key-rotation';
import { box, fail, info, initUI, ok } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host', '--container-scope'] as const;
function shouldWriteContainerVolume(args: string[]): boolean {
return args.includes('--container-scope') || fs.existsSync('/.dockerenv');
}
export async function handleFinalizeKeyRotation(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const remainingArgs = parsed.remainingArgs.filter((arg) => arg !== '--container-scope');
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
if (!shouldWriteContainerVolume(args)) {
try {
const output = new DockerExecutor().finalizeKeyRotation({ host: parsed.host });
if (output.trim()) {
console.log(output.trim());
}
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
return;
}
const status = finalizeDockerKeyRotation();
if (configExists(CLIPROXY_DEFAULT_PORT)) {
regenerateConfig(CLIPROXY_DEFAULT_PORT);
}
console.log(ok('Docker CLIProxy legacy API key grace period finalized.'));
console.log(info(`Current API key: ${status.maskedApiKey ?? '(not configured)'}`));
console.log(info('Restart CLIProxy if it is already running to reload the regenerated config.'));
}
+6
View File
@@ -19,6 +19,8 @@ export async function showHelp(): Promise<void> {
['update', 'Update CCS and CLIProxy inside the running container'],
['logs', 'Show or follow container log output'],
['config', 'Show bundled asset paths and deployment defaults'],
['show-key', 'Show the Docker CLIProxy API key masked by default'],
['finalize-key-rotation', 'End the legacy Docker API key grace period'],
],
],
[
@@ -35,6 +37,7 @@ export async function showHelp(): Promise<void> {
['up --proxy-port <port>', 'Publish CLIProxy on a custom host port'],
['logs --follow', 'Stream logs continuously'],
['logs --service <name>', 'Filter logs to ccs or cliproxy'],
['show-key --full', 'Reveal the full Docker CLIProxy API key'],
],
],
[
@@ -45,6 +48,9 @@ export async function showHelp(): Promise<void> {
['ccs docker --host my-box status', 'Use the documented common-option ordering'],
['ccs docker up --host my-box', 'Stage assets to ~/.ccs/docker and deploy remotely'],
['ccs docker logs --follow --service ccs', 'Tail dashboard logs only'],
['ccs docker show-key', 'Print the masked CLIProxy API key from the container'],
['ccs docker show-key --full', 'Reveal the full CLIProxy API key'],
['ccs docker finalize-key-rotation', 'Remove the legacy key immediately'],
['ccs docker update --host my-box', 'Update the running remote stack in place'],
],
],
+4
View File
@@ -1,8 +1,10 @@
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { handleConfig } from './config-subcommand';
import { handleDown } from './down-subcommand';
import { handleFinalizeKeyRotation } from './finalize-key-rotation-subcommand';
import { showHelp } from './help-subcommand';
import { handleLogs } from './logs-subcommand';
import { handleShowKey } from './show-key-subcommand';
import { handleStatus } from './status-subcommand';
import { handleUp } from './up-subcommand';
import { handleUpdate } from './update-subcommand';
@@ -44,6 +46,8 @@ export async function handleDockerCommand(args: string[]): Promise<void> {
update: handleUpdate,
logs: handleLogs,
config: handleConfig,
'show-key': handleShowKey,
'finalize-key-rotation': handleFinalizeKeyRotation,
help: async () => showHelp(),
};
@@ -0,0 +1,81 @@
import * as fs from 'fs';
import { DockerExecutor } from '../../docker';
import {
getDockerKeyRotationStatus,
renderDockerKeyRotationBanner,
} from '../../docker/docker-key-rotation';
import { box, fail, header, info, initUI } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host', '--full', '--container-scope', '--banner-only'] as const;
function shouldReadContainerVolume(args: string[]): boolean {
return args.includes('--container-scope') || fs.existsSync('/.dockerenv');
}
export async function handleShowKey(args: string[]): Promise<void> {
const bannerOnly = args.includes('--banner-only');
if (!bannerOnly) {
await initUI();
}
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const remainingArgs = parsed.remainingArgs.filter(
(arg) => arg !== '--full' && arg !== '--container-scope' && arg !== '--banner-only'
);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
const full = args.includes('--full');
if (!shouldReadContainerVolume(args)) {
try {
const output = new DockerExecutor().showKey({ host: parsed.host }, full);
if (output.trim()) {
console.log(output.trim());
}
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
return;
}
const status = getDockerKeyRotationStatus();
if (bannerOnly) {
const banner = renderDockerKeyRotationBanner(status);
if (banner) {
console.log(banner);
}
return;
}
const displayKey = full ? status.apiKey : status.maskedApiKey;
console.log(header('Docker CLIProxy API Key'));
console.log('');
console.log(info(`API key: ${displayKey ?? '(not configured)'}`));
if (status.legacyGraceActive && status.legacyGrace) {
console.log(info(`Legacy key: ${status.legacyGrace.legacyKey}`));
console.log(info(`Legacy key expires: ${status.legacyGrace.expiresAt}`));
} else {
console.log(info('Legacy key grace: inactive'));
}
if (status.stateCorrupted) {
console.log(info(`State marker was unreadable and will be recreated: ${status.statePath}`));
}
}
+4
View File
@@ -34,6 +34,10 @@ export async function handleUp(args: string[]): Promise<void> {
);
try {
await executor.up({ host: parsed.host, port, proxyPort });
const rotationBanner = executor.getKeyRotationBanner({ host: parsed.host });
if (rotationBanner) {
console.error(rotationBanner);
}
console.log(ok(`Docker stack is running${parsed.host ? ` on ${parsed.host}` : ' locally'}.`));
console.log(info(`Dashboard port: ${port}`));
console.log(info(`CLIProxy port: ${proxyPort}`));
+85 -13
View File
@@ -1,5 +1,6 @@
import { randomBytes } from 'crypto';
import { spawn } from 'child_process';
import * as fs from 'fs';
import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from '../cliproxy/binary-manager';
import {
configExists,
@@ -13,34 +14,105 @@ 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 { loadOrCreateUnifiedConfig, mutateConfig } from '../config/config-loader-facade';
import {
getConfigYamlPath,
loadOrCreateUnifiedConfig,
mutateConfig,
} from '../config/config-loader-facade';
import {
addLegacyKeyGrace,
createDockerBootstrapState,
DOCKER_LEGACY_API_KEY,
isDockerLegacyKeyGraceActive,
isLikelyDockerGeneratedApiKey,
readDockerBootstrapState,
shouldRestoreDockerLegacyApiKey,
writeDockerBootstrapState,
} from './docker-key-rotation';
function generateDockerSecret(): string {
return randomBytes(32).toString('base64url');
}
export function ensureDockerCliproxyAuth(): boolean {
const now = new Date();
const hadUnifiedConfig = fs.existsSync(getConfigYamlPath());
const hadCliproxyConfig = configExists(CLIPROXY_DEFAULT_PORT);
const cliproxyConfigPath = getCliproxyConfigPath();
const existingCliproxyConfig = hadCliproxyConfig
? fs.readFileSync(cliproxyConfigPath, 'utf8')
: '';
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;
const stateRead = readDockerBootstrapState();
const existingState = stateRead.state;
const existingApiKey = auth?.api_key;
if (!needsApiKey && !needsManagementSecret) {
return false;
let replacementApiKey = existingApiKey;
let configChanged = false;
if (needsApiKey || needsManagementSecret) {
mutateConfig((nextConfig) => {
nextConfig.cliproxy.auth ??= {};
if (needsApiKey) {
replacementApiKey = generateDockerSecret();
nextConfig.cliproxy.auth.api_key = replacementApiKey;
}
if (needsManagementSecret) {
nextConfig.cliproxy.auth.management_secret = generateDockerSecret();
}
});
configChanged = true;
}
mutateConfig((nextConfig) => {
nextConfig.cliproxy.auth ??= {};
if (needsApiKey) {
nextConfig.cliproxy.auth.api_key = generateDockerSecret();
}
if (needsManagementSecret) {
nextConfig.cliproxy.auth.management_secret = generateDockerSecret();
}
});
if (!replacementApiKey) {
replacementApiKey = loadOrCreateUnifiedConfig().cliproxy.auth?.api_key;
}
return true;
const existingLegacyKeyInCliproxyConfig = existingCliproxyConfig.includes(
`"${DOCKER_LEGACY_API_KEY}"`
);
const freshInstall = !hadUnifiedConfig && !hadCliproxyConfig;
const oldDefaultUpgrade = needsApiKey && !freshInstall;
const explicitLegacyRestore = shouldRestoreDockerLegacyApiKey();
const legacyRestoreEligible = !existingState?.legacyKeyGrace;
const alreadyBrokenUpgrade =
explicitLegacyRestore &&
legacyRestoreEligible &&
hadCliproxyConfig &&
isLikelyDockerGeneratedApiKey(replacementApiKey) &&
!existingLegacyKeyInCliproxyConfig;
const corruptedRecoverableUpgrade =
explicitLegacyRestore &&
stateRead.corrupted &&
hadCliproxyConfig &&
isLikelyDockerGeneratedApiKey(replacementApiKey);
let nextState = existingState ?? createDockerBootstrapState(replacementApiKey, now);
if (replacementApiKey && !nextState.apiKey) {
nextState = { ...nextState, apiKey: replacementApiKey };
}
if (
replacementApiKey &&
!isDockerLegacyKeyGraceActive(existingState, now) &&
(oldDefaultUpgrade || alreadyBrokenUpgrade || corruptedRecoverableUpgrade)
) {
nextState = addLegacyKeyGrace(nextState, replacementApiKey, now);
}
if (!existingState || stateRead.corrupted || nextState !== existingState) {
writeDockerBootstrapState(nextState);
}
const legacyShouldBePresent = isDockerLegacyKeyGraceActive(nextState, now);
const legacyPresenceChanged = existingLegacyKeyInCliproxyConfig !== legacyShouldBePresent;
return configChanged || legacyPresenceChanged;
}
async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> {
+59
View File
@@ -124,6 +124,11 @@ function runStreaming(command: string, args: string[]): Promise<void> {
});
}
function sleepSync(ms: number): void {
const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
}
let cachedLocalComposePrefix: string[] | undefined;
function resolveLocalComposePrefix(): string[] {
@@ -203,6 +208,8 @@ export class DockerExecutor {
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',
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS || '',
CCS_DOCKER_RESTORE_LEGACY_API_KEY: process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY || '',
},
REMOTE_DOCKER_BUILD_TIMEOUT_MS
),
@@ -265,6 +272,58 @@ export class DockerExecutor {
return result.stdout;
}
showKey(options: DockerCommandTarget, full: boolean): string {
const args = [
'exec',
DOCKER_CONTAINER_NAME,
'ccs',
'docker',
'show-key',
'--container-scope',
...(full ? ['--full'] : []),
];
const result = this.runDocker(args, options);
this.ensureSuccess(result, 'Docker key display', options);
return result.stdout;
}
finalizeKeyRotation(options: DockerCommandTarget): string {
const result = this.runDocker(
[
'exec',
DOCKER_CONTAINER_NAME,
'ccs',
'docker',
'finalize-key-rotation',
'--container-scope',
],
options
);
this.ensureSuccess(result, 'Docker key rotation finalization', options);
return result.stdout;
}
getKeyRotationBanner(options: DockerCommandTarget): string {
const args = [
'exec',
DOCKER_CONTAINER_NAME,
'ccs',
'docker',
'show-key',
'--container-scope',
'--full',
'--banner-only',
];
for (let attempt = 0; attempt < 10; attempt += 1) {
const result = this.runDocker(args, options, LOCAL_DOCKER_SYNC_TIMEOUT_MS);
if (result.exitCode === 0) {
return result.stdout.trim();
}
sleepSync(500);
}
return '';
}
private stageRemoteAssets(host: string): void {
this.ensureSuccess(
this.runSync('ssh', [host, `mkdir -p ${DOCKER_REMOTE_DIR}`], { remote: true }),
+202
View File
@@ -0,0 +1,202 @@
import * as fs from 'fs';
import * as path from 'path';
import { loadOrCreateUnifiedConfig, getCcsDir } from '../config/config-loader-facade';
export const DOCKER_BOOTSTRAP_STATE_FILENAME = '.docker-bootstrap-state.json';
export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed';
export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14;
export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS';
export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY';
const DAY_MS = 24 * 60 * 60 * 1000;
const STATE_VERSION = 1;
export interface DockerLegacyKeyGrace {
legacyKey: string;
replacementKey: string;
startedAt: string;
expiresAt: string;
finalizedAt?: string;
}
export interface DockerBootstrapState {
version: number;
apiKey?: string;
bootstrappedAt: string;
legacyKeyGrace?: DockerLegacyKeyGrace;
}
export interface DockerBootstrapStateReadResult {
state: DockerBootstrapState | null;
corrupted: boolean;
path: string;
}
export interface DockerKeyRotationStatus {
apiKey?: string;
maskedApiKey?: string;
statePath: string;
stateCorrupted: boolean;
legacyGraceActive: boolean;
legacyGrace?: DockerLegacyKeyGrace;
}
export function getDockerBootstrapStatePath(): string {
return path.join(getCcsDir(), 'cliproxy', DOCKER_BOOTSTRAP_STATE_FILENAME);
}
export function parseDockerLegacyKeyGraceDays(env = process.env): number {
const rawValue = env[DOCKER_LEGACY_KEY_GRACE_ENV];
if (rawValue === undefined || rawValue.trim() === '') {
return DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS;
}
return Math.floor(parsed);
}
export function isLikelyDockerGeneratedApiKey(value: string | undefined): boolean {
return Boolean(value && /^[A-Za-z0-9_-]{43}$/.test(value));
}
export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean {
return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1';
}
export function readDockerBootstrapState(): DockerBootstrapStateReadResult {
const statePath = getDockerBootstrapStatePath();
if (!fs.existsSync(statePath)) {
return { state: null, corrupted: false, path: statePath };
}
try {
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as DockerBootstrapState;
if (!parsed || parsed.version !== STATE_VERSION || typeof parsed.bootstrappedAt !== 'string') {
return { state: null, corrupted: true, path: statePath };
}
return { state: parsed, corrupted: false, path: statePath };
} catch {
return { state: null, corrupted: true, path: statePath };
}
}
export function writeDockerBootstrapState(state: DockerBootstrapState): void {
const statePath = getDockerBootstrapStatePath();
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, statePath);
}
export function createDockerBootstrapState(
apiKey: string | undefined,
now = new Date()
): DockerBootstrapState {
return {
version: STATE_VERSION,
apiKey,
bootstrappedAt: now.toISOString(),
};
}
export function addLegacyKeyGrace(
state: DockerBootstrapState,
replacementKey: string,
now = new Date(),
graceDays = parseDockerLegacyKeyGraceDays()
): DockerBootstrapState {
return {
...state,
apiKey: replacementKey,
legacyKeyGrace: {
legacyKey: DOCKER_LEGACY_API_KEY,
replacementKey,
startedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + graceDays * DAY_MS).toISOString(),
},
};
}
export function isDockerLegacyKeyGraceActive(
state: DockerBootstrapState | null,
now = new Date()
): boolean {
const grace = state?.legacyKeyGrace;
if (!grace || grace.finalizedAt) {
return false;
}
const expiresAt = Date.parse(grace.expiresAt);
return Number.isFinite(expiresAt) && expiresAt > now.getTime();
}
export function getActiveDockerLegacyApiKeys(now = new Date()): string[] {
const { state } = readDockerBootstrapState();
if (!isDockerLegacyKeyGraceActive(state, now)) {
return [];
}
return state?.legacyKeyGrace?.legacyKey ? [state.legacyKeyGrace.legacyKey] : [];
}
export function maskDockerApiKey(apiKey: string | undefined): string | undefined {
if (!apiKey) {
return undefined;
}
if (apiKey.length <= 8) {
return '****';
}
return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`;
}
export function getDockerKeyRotationStatus(now = new Date()): DockerKeyRotationStatus {
const config = loadOrCreateUnifiedConfig();
const readResult = readDockerBootstrapState();
const apiKey = config.cliproxy.auth?.api_key;
return {
apiKey,
maskedApiKey: maskDockerApiKey(apiKey),
statePath: readResult.path,
stateCorrupted: readResult.corrupted,
legacyGraceActive: isDockerLegacyKeyGraceActive(readResult.state, now),
legacyGrace: readResult.state?.legacyKeyGrace,
};
}
export function renderDockerKeyRotationBanner(status = getDockerKeyRotationStatus()): string {
if (!status.legacyGraceActive || !status.legacyGrace) {
return '';
}
return [
'[!] Docker CLIProxy API key rotation grace period is active.',
`[i] New CLIProxy API key: ${status.legacyGrace.replacementKey}`,
`[i] Legacy key ${status.legacyGrace.legacyKey} remains valid until ${status.legacyGrace.expiresAt}.`,
'[i] Update existing clients, then run `ccs docker finalize-key-rotation`.',
].join('\n');
}
export function finalizeDockerKeyRotation(now = new Date()): DockerKeyRotationStatus {
const readResult = readDockerBootstrapState();
const state =
readResult.state ??
createDockerBootstrapState(loadOrCreateUnifiedConfig().cliproxy.auth?.api_key, now);
writeDockerBootstrapState({
...state,
legacyKeyGrace: state.legacyKeyGrace
? {
...state.legacyKeyGrace,
finalizedAt: now.toISOString(),
expiresAt: now.toISOString(),
}
: undefined,
});
return getDockerKeyRotationStatus(now);
}
+44
View File
@@ -16,6 +16,7 @@ import {
runImageAnalysisCheck,
} from './checks';
import { runAutoRepair } from './repair';
import { getDockerKeyRotationStatus } from '../docker/docker-key-rotation';
/**
* Doctor Class - Orchestrates health checks
@@ -55,6 +56,7 @@ class Doctor {
// Group 3: Configuration
console.log(header('CONFIGURATION'));
runConfigChecks(this.results);
this.runDockerKeyRotationCheck();
console.log('');
// Group 4: Profiles & Delegation
@@ -158,6 +160,48 @@ class Doctor {
console.log('');
}
private runDockerKeyRotationCheck(): void {
const status = getDockerKeyRotationStatus();
if (status.legacyGraceActive && status.legacyGrace) {
this.results.addCheck(
'Docker Key Rotation',
'warning',
`Legacy API key remains valid until ${status.legacyGrace.expiresAt}`,
'Run `ccs docker show-key --full`, update clients, then run `ccs docker finalize-key-rotation`.',
{
status: 'WARN',
info: `legacy key grace active until ${status.legacyGrace.expiresAt}`,
}
);
return;
}
if (status.stateCorrupted) {
this.results.addCheck(
'Docker Key Rotation',
'warning',
'Docker bootstrap state marker is unreadable',
'Run `ccs docker up` to recreate the marker.',
{
status: 'WARN',
info: 'state marker unreadable',
}
);
return;
}
this.results.addCheck(
'Docker Key Rotation',
'success',
'No active legacy API key grace',
undefined,
{
status: 'OK',
info: 'no active grace',
}
);
}
/**
* Generate JSON report
*/
@@ -154,6 +154,10 @@ describe('completion backend', () => {
test('includes live doctor and cliproxy flags from the shared catalog', () => {
expect(suggestionValues(['doctor'])).toEqual(expect.arrayContaining(['--fix', '-f']));
expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend']));
expect(suggestionValues(['docker'])).toEqual(
expect.arrayContaining(['show-key', 'finalize-key-rotation'])
);
expect(suggestionValues(['docker', 'show-key'])).toEqual(expect.arrayContaining(['--full']));
});
test('suggests browser subcommands and fix/setup flags', () => {
@@ -49,6 +49,18 @@ beforeEach(() => {
calls.push(`config:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/show-key-subcommand', () => ({
handleShowKey: async (args: string[]) => {
calls.push(`show-key:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/finalize-key-rotation-subcommand', () => ({
handleFinalizeKeyRotation: async (args: string[]) => {
calls.push(`finalize-key-rotation:${args.join(' ')}`);
},
}));
});
afterEach(() => {
@@ -104,4 +116,13 @@ describe('docker command', () => {
expect(calls).toEqual(['help']);
expect(process.exitCode).toBe(1);
});
it('routes Docker key rotation subcommands', async () => {
const handleDockerCommand = await loadHandleDockerCommand();
await handleDockerCommand(['show-key', '--full']);
await handleDockerCommand(['finalize-key-rotation']);
expect(calls).toEqual(['show-key:--full', 'finalize-key-rotation:']);
});
});
@@ -0,0 +1,95 @@
import { mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, describe, expect, it } from 'bun:test';
import {
renderCapturedLines,
useDockerSubcommandConsoleCapture,
} from './docker-subcommand-test-helpers';
import { ensureDockerCliproxyAuth } from '../../../src/docker/docker-bootstrap';
import { mutateConfig } from '../../../src/config/config-loader-facade';
import {
CCS_INTERNAL_API_KEY,
regenerateConfig,
} from '../../../src/cliproxy/config/config-generator';
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
import { readDockerBootstrapState } from '../../../src/docker/docker-key-rotation';
const capture = useDockerSubcommandConsoleCapture();
const originalCcsHome = process.env.CCS_HOME;
const tempDirs: string[] = [];
function useTempCcsHome(): void {
const dir = mkdtempSync(join(tmpdir(), 'ccs-docker-key-command-'));
tempDirs.push(dir);
process.env.CCS_HOME = dir;
}
async function loadHandleShowKey() {
const mod = await import(
`../../../src/commands/docker/show-key-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleShowKey;
}
async function loadHandleFinalizeKeyRotation() {
const mod = await import(
`../../../src/commands/docker/finalize-key-rotation-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleFinalizeKeyRotation;
}
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 key rotation subcommands', () => {
it('shows the Docker API key masked by default and full with --full', async () => {
useTempCcsHome();
mutateConfig((config) => {
config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY };
});
ensureDockerCliproxyAuth();
const replacementKey = readDockerBootstrapState().state?.legacyKeyGrace?.replacementKey;
const handleShowKey = await loadHandleShowKey();
await handleShowKey(['--container-scope']);
const maskedOutput = renderCapturedLines(capture.logLines);
capture.logLines.length = 0;
await handleShowKey(['--container-scope', '--full']);
const fullOutput = renderCapturedLines(capture.logLines);
expect(replacementKey).toBeTruthy();
expect(maskedOutput).toContain('API key:');
expect(maskedOutput).not.toContain(replacementKey ?? '');
expect(fullOutput).toContain(`API key: ${replacementKey}`);
expect(fullOutput).toContain('Legacy key expires:');
});
it('finalizes active legacy-key grace immediately', async () => {
useTempCcsHome();
mutateConfig((config) => {
config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY };
});
ensureDockerCliproxyAuth();
const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT);
const handleFinalize = await loadHandleFinalizeKeyRotation();
await handleFinalize(['--container-scope']);
const state = readDockerBootstrapState().state;
const content = readFileSync(configPath, 'utf8');
expect(state?.legacyKeyGrace?.finalizedAt).toBeTruthy();
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
expect(renderCapturedLines(capture.logLines)).toContain(
'Docker CLIProxy legacy API key grace period finalized.'
);
});
});
@@ -20,6 +20,7 @@ describe('docker up subcommand', () => {
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalUp = dockerModule.DockerExecutor.prototype.up;
const originalGetKeyRotationBanner = dockerModule.DockerExecutor.prototype.getKeyRotationBanner;
dockerModule.DockerExecutor.prototype.up = function (options: {
host?: string;
port: number;
@@ -27,6 +28,13 @@ describe('docker up subcommand', () => {
}) {
calls.push(options);
};
dockerModule.DockerExecutor.prototype.getKeyRotationBanner = function () {
return [
'[!] Docker CLIProxy API key rotation grace period is active.',
'[i] New CLIProxy API key: new-secret',
'[i] Legacy key ccs-internal-managed remains valid until 2026-06-05T00:00:00.000Z.',
].join('\n');
};
try {
const handleUp = await loadHandleUp();
@@ -40,10 +48,14 @@ describe('docker up subcommand', () => {
expect(rendered).toContain('CLIProxy port: 9317');
expect(rendered).toContain('Full remote management requires dashboard auth');
expect(rendered).toContain('Without it, remote access stays read-only.');
expect(capture.errorLines).toEqual([]);
expect(renderCapturedLines(capture.errorLines)).toContain(
'Docker CLIProxy API key rotation grace period is active'
);
expect(renderCapturedLines(capture.errorLines)).toContain('New CLIProxy API key: new-secret');
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.up = originalUp;
dockerModule.DockerExecutor.prototype.getKeyRotationBanner = originalGetKeyRotationBanner;
}
});
@@ -31,4 +31,26 @@ describe('docker bundled assets', () => {
expect(compose).not.toContain('"${CCS_DASHBOARD_PORT:-3000}:3000"');
expect(compose).not.toContain('"${CCS_CLIPROXY_PORT:-8317}:8317"');
});
it('passes Docker key rotation controls into the integrated container', () => {
const compose = readFileSync(assets.composeFile, 'utf8');
expect(compose).toContain(
'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"'
);
expect(compose).toContain(
'CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"'
);
});
it('passes Docker key rotation controls into the public compose file', () => {
const compose = readFileSync('docker/compose.yaml', 'utf8');
expect(compose).toContain(
'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"'
);
expect(compose).toContain(
'CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"'
);
});
});
+188 -1
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from 'fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, describe, expect, it } from 'bun:test';
@@ -7,9 +7,22 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../../src/config/con
import {
CCS_CONTROL_PANEL_SECRET,
CCS_INTERNAL_API_KEY,
generateConfig,
regenerateConfig,
} from '../../../src/cliproxy/config/config-generator';
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
import { getConfigPathForPort } from '../../../src/cliproxy/config/path-resolver';
import {
DOCKER_BOOTSTRAP_STATE_FILENAME,
DOCKER_LEGACY_API_KEY,
getDockerBootstrapStatePath,
parseDockerLegacyKeyGraceDays,
readDockerBootstrapState,
} from '../../../src/docker/docker-key-rotation';
const originalCcsHome = process.env.CCS_HOME;
const originalGraceDays = process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS;
const originalRestoreLegacyKey = process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY;
const tempDirs: string[] = [];
function useTempCcsHome(): string {
@@ -25,6 +38,16 @@ afterEach(() => {
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (originalGraceDays === undefined) {
delete process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS;
} else {
process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS = originalGraceDays;
}
if (originalRestoreLegacyKey === undefined) {
delete process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY;
} else {
process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = originalRestoreLegacyKey;
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
@@ -44,6 +67,17 @@ describe('docker bootstrap auth', () => {
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);
expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined();
});
it('keeps fresh generated CLIProxy config free of the legacy key', () => {
useTempCcsHome();
ensureDockerCliproxyAuth();
const configPath = generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
expect(content).not.toContain(DOCKER_LEGACY_API_KEY);
});
it('preserves custom CLIProxy auth values for Docker deployments', () => {
@@ -61,5 +95,158 @@ describe('docker bootstrap auth', () => {
expect(changed).toBe(false);
expect(config.cliproxy.auth?.api_key).toBe('custom-api-key');
expect(config.cliproxy.auth?.management_secret).toBe('custom-management-secret');
expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined();
});
it('preserves the legacy key during the default upgrade grace window', () => {
useTempCcsHome();
mutateConfig((config) => {
config.cliproxy.auth = {
api_key: CCS_INTERNAL_API_KEY,
management_secret: CCS_CONTROL_PANEL_SECRET,
};
});
const changed = ensureDockerCliproxyAuth();
const config = loadOrCreateUnifiedConfig();
const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
const state = readDockerBootstrapState().state;
expect(changed).toBe(true);
expect(config.cliproxy.auth?.api_key).not.toBe(CCS_INTERNAL_API_KEY);
expect(state?.legacyKeyGrace?.legacyKey).toBe(CCS_INTERNAL_API_KEY);
expect(state?.legacyKeyGrace?.replacementKey).toBe(config.cliproxy.auth?.api_key);
expect(content).toContain(`"${config.cliproxy.auth?.api_key}"`);
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
});
it('honors CCS_DOCKER_LEGACY_KEY_GRACE_DAYS for upgrade expiry', () => {
useTempCcsHome();
process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS = '1';
mutateConfig((config) => {
config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY };
});
ensureDockerCliproxyAuth();
const state = readDockerBootstrapState().state;
const startedAt = Date.parse(state?.legacyKeyGrace?.startedAt ?? '');
const expiresAt = Date.parse(state?.legacyKeyGrace?.expiresAt ?? '');
expect(parseDockerLegacyKeyGraceDays()).toBe(1);
expect(expiresAt - startedAt).toBe(24 * 60 * 60 * 1000);
});
it('drops the legacy key when the grace window has already expired', () => {
useTempCcsHome();
process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS = '0';
mutateConfig((config) => {
config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY };
});
ensureDockerCliproxyAuth();
const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
expect(content).not.toContain(CCS_INTERNAL_API_KEY);
});
it('does not auto-add the legacy key for custom generated-looking API keys', () => {
useTempCcsHome();
const generatedLookingCustomKey = 'A'.repeat(43);
mutateConfig((config) => {
config.cliproxy.auth = {
api_key: generatedLookingCustomKey,
management_secret: 'custom-management-secret',
};
});
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
const changed = ensureDockerCliproxyAuth();
const content = readFileSync(getConfigPathForPort(CLIPROXY_DEFAULT_PORT), 'utf8');
expect(changed).toBe(false);
expect(content).toContain(`"${generatedLookingCustomKey}"`);
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
});
it('restores the legacy key for already-broken random-key installs when explicitly requested', () => {
useTempCcsHome();
process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = '1';
const generatedKey = 'A'.repeat(43);
mutateConfig((config) => {
config.cliproxy.auth = {
api_key: generatedKey,
management_secret: 'custom-management-secret',
};
});
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
const changed = ensureDockerCliproxyAuth();
const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
expect(changed).toBe(true);
expect(content).toContain(`"${generatedKey}"`);
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
});
it('restores the legacy key after an earlier run wrote a no-grace marker', () => {
useTempCcsHome();
const generatedKey = 'C'.repeat(43);
mutateConfig((config) => {
config.cliproxy.auth = {
api_key: generatedKey,
management_secret: 'custom-management-secret',
};
});
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
expect(ensureDockerCliproxyAuth()).toBe(false);
expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined();
process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = '1';
const changed = ensureDockerCliproxyAuth();
const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
expect(changed).toBe(true);
expect(content).toContain(`"${generatedKey}"`);
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
});
it('recovers safely from a corrupted marker file during broken-install recovery', () => {
useTempCcsHome();
process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = '1';
const generatedKey = 'B'.repeat(43);
mutateConfig((config) => {
config.cliproxy.auth = { api_key: generatedKey };
});
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
mkdirSync(join(getDockerBootstrapStatePath(), '..'), { recursive: true });
writeFileSync(getDockerBootstrapStatePath(), '{not-json');
ensureDockerCliproxyAuth();
const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
expect(getDockerBootstrapStatePath()).toContain(DOCKER_BOOTSTRAP_STATE_FILENAME);
expect(readDockerBootstrapState().corrupted).toBe(false);
expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`);
});
it('does not treat human custom keys as broken Docker-generated keys', () => {
useTempCcsHome();
mutateConfig((config) => {
config.cliproxy.auth = { api_key: 'custom-human-key' };
});
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
const changed = ensureDockerCliproxyAuth();
const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
const content = readFileSync(configPath, 'utf8');
expect(changed).toBe(true);
expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`);
});
});
@@ -54,6 +54,8 @@ describe('docker executor', () => {
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?.env?.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS).toBe('');
expect(calls[0].options?.env?.CCS_DOCKER_RESTORE_LEGACY_API_KEY).toBe('');
expect(calls[0].options?.timeoutMs).toBe(300_000);
});
@@ -91,6 +93,8 @@ describe('docker executor', () => {
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("export CCS_DOCKER_LEGACY_KEY_GRACE_DAYS=''");
expect(calls[2].args[1]).toContain("export CCS_DOCKER_RESTORE_LEGACY_API_KEY=''");
expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1');
expect(calls[2].options?.timeoutMs).toBe(300_000);
});