fix: preserve Docker legacy API key during rotation

This commit is contained in:
Tam Nhu Tran committed 2026-05-22 16:49:16 -04:00
1 parent 6088ebaa09
commit 30971ebb28
22 files changed
+928 -18

No files matched your search

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