mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
fix: preserve Docker legacy API key during rotation
This commit is contained in:
1 parent
6088ebaa09
commit
30971ebb28
22 files changed
+928
-18
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user