mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(proxy): avoid exposing local auth token in daemon argv (#1230)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import * as fs from 'fs';
|
||||
import { resolveOpenAICompatProfileConfig } from './profile-router';
|
||||
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
|
||||
import { startOpenAICompatProxyServer } from './server/proxy-server';
|
||||
@@ -9,6 +10,7 @@ interface RuntimeOptions {
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
authToken: string;
|
||||
authTokenFile: string;
|
||||
insecure: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ function parseArgs(argv: string[]): RuntimeOptions {
|
||||
let profileName = '';
|
||||
let settingsPath = '';
|
||||
let authToken = '';
|
||||
let authTokenFile = '';
|
||||
let insecure = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -42,16 +45,36 @@ function parseArgs(argv: string[]): RuntimeOptions {
|
||||
authToken = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--auth-token-file' && argv[i + 1]) {
|
||||
authTokenFile = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--insecure') {
|
||||
insecure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { port, host, profileName, settingsPath, authToken, insecure };
|
||||
return { port, host, profileName, settingsPath, authToken, authTokenFile, insecure };
|
||||
}
|
||||
|
||||
function readAuthToken(options: RuntimeOptions): string {
|
||||
if (options.authTokenFile) {
|
||||
try {
|
||||
const token = fs.readFileSync(options.authTokenFile, 'utf8').trim();
|
||||
fs.unlinkSync(options.authTokenFile);
|
||||
return token;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read local proxy auth token file: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options.authToken;
|
||||
}
|
||||
|
||||
function startRuntime(options: RuntimeOptions): void {
|
||||
if (!options.authToken.trim()) {
|
||||
const authToken = readAuthToken(options);
|
||||
|
||||
if (!authToken.trim()) {
|
||||
throw new Error('Missing local proxy auth token');
|
||||
}
|
||||
|
||||
@@ -71,7 +94,7 @@ function startRuntime(options: RuntimeOptions): void {
|
||||
profile,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
authToken: options.authToken,
|
||||
authToken,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
server.once('error', (error) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
@@ -20,7 +21,40 @@ export interface OpenAICompatProxySession {
|
||||
}
|
||||
|
||||
function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
const proxyDir = getOpenAICompatProxyDir();
|
||||
fs.mkdirSync(proxyDir, { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(proxyDir, 0o700);
|
||||
}
|
||||
|
||||
function encodeProfileFileKey(profileName: string): string {
|
||||
return encodeURIComponent(profileName);
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyAuthTokenFile(
|
||||
profileName: string,
|
||||
authToken: string
|
||||
): string {
|
||||
ensureProxyDir();
|
||||
const tokenPath = path.join(
|
||||
getOpenAICompatProxyDir(),
|
||||
`.${encodeProfileFileKey(profileName)}.${crypto.randomBytes(8).toString('hex')}.token`
|
||||
);
|
||||
const fd = fs.openSync(tokenPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(fd, authToken, 'utf8');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.chmodSync(tokenPath, 0o600);
|
||||
return tokenPath;
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyAuthTokenFile(tokenPath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(tokenPath);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function readPid(pidPath: string): number | null {
|
||||
@@ -43,7 +77,11 @@ export function getLegacyOpenAICompatProxyPid(): number | null {
|
||||
|
||||
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8');
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(getOpenAICompatProxyPidPath(profileName), 0o600);
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(profileName: string): void {
|
||||
@@ -80,11 +118,12 @@ export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession |
|
||||
|
||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath(session.profileName),
|
||||
JSON.stringify(session, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
const sessionPath = getOpenAICompatProxySessionPath(session.profileName);
|
||||
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2) + '\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(sessionPath, 0o600);
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxySession(profileName: string): void {
|
||||
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
readOpenAICompatProxySession,
|
||||
removeLegacyOpenAICompatProxyPid,
|
||||
removeLegacyOpenAICompatProxySession,
|
||||
removeOpenAICompatProxyAuthTokenFile,
|
||||
removeOpenAICompatProxyPid,
|
||||
removeOpenAICompatProxySession,
|
||||
resolveOpenAICompatProxyEntrypointCandidates,
|
||||
type OpenAICompatProxySession,
|
||||
writeOpenAICompatProxyAuthTokenFile,
|
||||
writeOpenAICompatProxyPid,
|
||||
writeOpenAICompatProxySession,
|
||||
} from './proxy-daemon-state';
|
||||
@@ -82,7 +84,8 @@ function generateProxyAuthToken(): string {
|
||||
|
||||
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const proxyDir = getOpenAICompatProxyDir();
|
||||
await fs.promises.mkdir(proxyDir, { recursive: true });
|
||||
await fs.promises.mkdir(proxyDir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.chmod(proxyDir, 0o700);
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
@@ -543,6 +546,7 @@ export async function startOpenAICompatProxy(
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let stderr = '';
|
||||
const authToken = generateProxyAuthToken();
|
||||
const authTokenFile = writeOpenAICompatProxyAuthTokenFile(profile.profileName, authToken);
|
||||
const commitState = () => {
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
||||
@@ -563,9 +567,12 @@ export async function startOpenAICompatProxy(
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success && persistState) {
|
||||
removeOpenAICompatProxyPid(profile.profileName);
|
||||
removeOpenAICompatProxySession(profile.profileName);
|
||||
if (!result.success) {
|
||||
removeOpenAICompatProxyAuthTokenFile(authTokenFile);
|
||||
if (persistState) {
|
||||
removeOpenAICompatProxyPid(profile.profileName);
|
||||
removeOpenAICompatProxySession(profile.profileName);
|
||||
}
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
@@ -582,8 +589,8 @@ export async function startOpenAICompatProxy(
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
profile.settingsPath,
|
||||
'--auth-token',
|
||||
authToken,
|
||||
'--auth-token-file',
|
||||
authTokenFile,
|
||||
...(options.insecure ? ['--insecure'] : []),
|
||||
'--ccs-openai-proxy-daemon',
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -71,6 +72,27 @@ async function getPortOutsideOpenAICompatAdaptiveRange(): Promise<number> {
|
||||
throw new Error('No stale proxy fixture port found outside the adaptive range');
|
||||
}
|
||||
|
||||
function readProcessCommandLine(pid: number): string | null {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
||||
return commandLine.split('\u0000').filter(Boolean).join(' ') || null;
|
||||
} catch {
|
||||
// Fall back to ps below for platforms without readable procfs entries.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
}).trim() || null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('openai proxy daemon lifecycle', () => {
|
||||
it('starts, reports status, serves health/models, and stops', async () => {
|
||||
const port = await getPort();
|
||||
@@ -101,18 +123,42 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.authToken).toBeTruthy();
|
||||
const authToken = started.authToken;
|
||||
if (!authToken) {
|
||||
throw new Error('Expected proxy auth token');
|
||||
}
|
||||
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.profileName).toBe('hf');
|
||||
expect(status.authToken).toBe(started.authToken);
|
||||
expect(status.authToken).toBe(authToken);
|
||||
|
||||
const sessionPath = getOpenAICompatProxySessionPath('hf');
|
||||
const proxyDir = path.dirname(sessionPath);
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(sessionPath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
expect(fs.readdirSync(proxyDir).some((entry) => entry.endsWith('.token'))).toBe(false);
|
||||
if (started.pid) {
|
||||
const commandLine = readProcessCommandLine(started.pid);
|
||||
if (!commandLine) {
|
||||
console.warn(
|
||||
`Skipping daemon argv assertion: could not read command line for PID ${started.pid}`
|
||||
);
|
||||
} else {
|
||||
expect(commandLine).toContain('--auth-token-file');
|
||||
expect(commandLine).not.toContain(authToken);
|
||||
expect(commandLine).not.toMatch(/(^|\s)--auth-token(?:\s|=|$)/);
|
||||
}
|
||||
}
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const models = (await (
|
||||
await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
headers: { 'x-api-key': started.authToken! },
|
||||
headers: { 'x-api-key': authToken },
|
||||
})
|
||||
).json()) as { data?: Array<{ id: string }> };
|
||||
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
|
||||
|
||||
@@ -3,11 +3,16 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from '../../../src/proxy/proxy-daemon-paths';
|
||||
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
|
||||
import {
|
||||
listOpenAICompatProxyProfileNames,
|
||||
removeOpenAICompatProxyAuthTokenFile,
|
||||
writeOpenAICompatProxyAuthTokenFile,
|
||||
} from '../../../src/proxy/proxy-daemon-state';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
@@ -51,3 +56,24 @@ describe('listOpenAICompatProxyProfileNames', () => {
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeOpenAICompatProxyAuthTokenFile', () => {
|
||||
it('writes a private token file under the CCS proxy dir and removes it', () => {
|
||||
const authToken = 'proxy-token-regression-secret';
|
||||
const tokenPath = writeOpenAICompatProxyAuthTokenFile('ccg', authToken);
|
||||
const proxyDir = getOpenAICompatProxyDir();
|
||||
|
||||
expect(path.dirname(tokenPath)).toBe(proxyDir);
|
||||
expect(tokenPath.startsWith(`${proxyDir}${path.sep}`)).toBe(true);
|
||||
expect(fs.readFileSync(tokenPath, 'utf8')).toBe(authToken);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyAuthTokenFile(tokenPath);
|
||||
|
||||
expect(fs.existsSync(tokenPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user