mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix: preserve Docker legacy API key during rotation
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:-}"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user