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

+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
*/