mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix(proxy): avoid exposing local auth token in daemon argv (#1230)
This commit is contained in:
1 parent
9c5d4976c5
commit
a840793a9b
5 files changed
+160
-19
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
import { resolveOpenAICompatProfileConfig } from './profile-router';
|
import { resolveOpenAICompatProfileConfig } from './profile-router';
|
||||||
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
|
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
|
||||||
import { startOpenAICompatProxyServer } from './server/proxy-server';
|
import { startOpenAICompatProxyServer } from './server/proxy-server';
|
||||||
@@ -9,6 +10,7 @@ interface RuntimeOptions {
|
|||||||
profileName: string;
|
profileName: string;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
authToken: string;
|
authToken: string;
|
||||||
|
authTokenFile: string;
|
||||||
insecure: boolean;
|
insecure: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ function parseArgs(argv: string[]): RuntimeOptions {
|
|||||||
let profileName = '';
|
let profileName = '';
|
||||||
let settingsPath = '';
|
let settingsPath = '';
|
||||||
let authToken = '';
|
let authToken = '';
|
||||||
|
let authTokenFile = '';
|
||||||
let insecure = false;
|
let insecure = false;
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
@@ -42,16 +45,36 @@ function parseArgs(argv: string[]): RuntimeOptions {
|
|||||||
authToken = argv[++i] || '';
|
authToken = argv[++i] || '';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (arg === '--auth-token-file' && argv[i + 1]) {
|
||||||
|
authTokenFile = argv[++i] || '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (arg === '--insecure') {
|
if (arg === '--insecure') {
|
||||||
insecure = true;
|
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 {
|
function startRuntime(options: RuntimeOptions): void {
|
||||||
if (!options.authToken.trim()) {
|
const authToken = readAuthToken(options);
|
||||||
|
|
||||||
|
if (!authToken.trim()) {
|
||||||
throw new Error('Missing local proxy auth token');
|
throw new Error('Missing local proxy auth token');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +94,7 @@ function startRuntime(options: RuntimeOptions): void {
|
|||||||
profile,
|
profile,
|
||||||
host: options.host,
|
host: options.host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
authToken: options.authToken,
|
authToken,
|
||||||
insecure: options.insecure,
|
insecure: options.insecure,
|
||||||
});
|
});
|
||||||
server.once('error', (error) => {
|
server.once('error', (error) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +21,40 @@ export interface OpenAICompatProxySession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureProxyDir(): void {
|
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 {
|
function readPid(pidPath: string): number | null {
|
||||||
@@ -43,7 +77,11 @@ export function getLegacyOpenAICompatProxyPid(): number | null {
|
|||||||
|
|
||||||
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
||||||
ensureProxyDir();
|
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 {
|
export function removeOpenAICompatProxyPid(profileName: string): void {
|
||||||
@@ -80,11 +118,12 @@ export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession |
|
|||||||
|
|
||||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||||
ensureProxyDir();
|
ensureProxyDir();
|
||||||
fs.writeFileSync(
|
const sessionPath = getOpenAICompatProxySessionPath(session.profileName);
|
||||||
getOpenAICompatProxySessionPath(session.profileName),
|
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2) + '\n', {
|
||||||
JSON.stringify(session, null, 2) + '\n',
|
encoding: 'utf8',
|
||||||
'utf8'
|
mode: 0o600,
|
||||||
);
|
});
|
||||||
|
fs.chmodSync(sessionPath, 0o600);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeOpenAICompatProxySession(profileName: string): void {
|
export function removeOpenAICompatProxySession(profileName: string): void {
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
readOpenAICompatProxySession,
|
readOpenAICompatProxySession,
|
||||||
removeLegacyOpenAICompatProxyPid,
|
removeLegacyOpenAICompatProxyPid,
|
||||||
removeLegacyOpenAICompatProxySession,
|
removeLegacyOpenAICompatProxySession,
|
||||||
|
removeOpenAICompatProxyAuthTokenFile,
|
||||||
removeOpenAICompatProxyPid,
|
removeOpenAICompatProxyPid,
|
||||||
removeOpenAICompatProxySession,
|
removeOpenAICompatProxySession,
|
||||||
resolveOpenAICompatProxyEntrypointCandidates,
|
resolveOpenAICompatProxyEntrypointCandidates,
|
||||||
type OpenAICompatProxySession,
|
type OpenAICompatProxySession,
|
||||||
|
writeOpenAICompatProxyAuthTokenFile,
|
||||||
writeOpenAICompatProxyPid,
|
writeOpenAICompatProxyPid,
|
||||||
writeOpenAICompatProxySession,
|
writeOpenAICompatProxySession,
|
||||||
} from './proxy-daemon-state';
|
} from './proxy-daemon-state';
|
||||||
@@ -82,7 +84,8 @@ function generateProxyAuthToken(): string {
|
|||||||
|
|
||||||
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
|
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
const proxyDir = getOpenAICompatProxyDir();
|
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;
|
let release: (() => Promise<void>) | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -543,6 +546,7 @@ export async function startOpenAICompatProxy(
|
|||||||
let timeout: NodeJS.Timeout | null = null;
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
const authToken = generateProxyAuthToken();
|
const authToken = generateProxyAuthToken();
|
||||||
|
const authTokenFile = writeOpenAICompatProxyAuthTokenFile(profile.profileName, authToken);
|
||||||
const commitState = () => {
|
const commitState = () => {
|
||||||
if (proc.pid) {
|
if (proc.pid) {
|
||||||
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
||||||
@@ -563,9 +567,12 @@ export async function startOpenAICompatProxy(
|
|||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
if (!result.success && persistState) {
|
if (!result.success) {
|
||||||
removeOpenAICompatProxyPid(profile.profileName);
|
removeOpenAICompatProxyAuthTokenFile(authTokenFile);
|
||||||
removeOpenAICompatProxySession(profile.profileName);
|
if (persistState) {
|
||||||
|
removeOpenAICompatProxyPid(profile.profileName);
|
||||||
|
removeOpenAICompatProxySession(profile.profileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
@@ -582,8 +589,8 @@ export async function startOpenAICompatProxy(
|
|||||||
profile.profileName,
|
profile.profileName,
|
||||||
'--settings-path',
|
'--settings-path',
|
||||||
profile.settingsPath,
|
profile.settingsPath,
|
||||||
'--auth-token',
|
'--auth-token-file',
|
||||||
authToken,
|
authTokenFile,
|
||||||
...(options.insecure ? ['--insecure'] : []),
|
...(options.insecure ? ['--insecure'] : []),
|
||||||
'--ccs-openai-proxy-daemon',
|
'--ccs-openai-proxy-daemon',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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');
|
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', () => {
|
describe('openai proxy daemon lifecycle', () => {
|
||||||
it('starts, reports status, serves health/models, and stops', async () => {
|
it('starts, reports status, serves health/models, and stops', async () => {
|
||||||
const port = await getPort();
|
const port = await getPort();
|
||||||
@@ -101,18 +123,42 @@ describe('openai proxy daemon lifecycle', () => {
|
|||||||
const started = await startOpenAICompatProxy(profile, { port });
|
const started = await startOpenAICompatProxy(profile, { port });
|
||||||
expect(started.success).toBe(true);
|
expect(started.success).toBe(true);
|
||||||
expect(started.authToken).toBeTruthy();
|
expect(started.authToken).toBeTruthy();
|
||||||
|
const authToken = started.authToken;
|
||||||
|
if (!authToken) {
|
||||||
|
throw new Error('Expected proxy auth token');
|
||||||
|
}
|
||||||
|
|
||||||
const status = await getOpenAICompatProxyStatus();
|
const status = await getOpenAICompatProxyStatus();
|
||||||
expect(status.running).toBe(true);
|
expect(status.running).toBe(true);
|
||||||
expect(status.profileName).toBe('hf');
|
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`);
|
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||||
expect(health.status).toBe(200);
|
expect(health.status).toBe(200);
|
||||||
|
|
||||||
const models = (await (
|
const models = (await (
|
||||||
await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
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 }> };
|
).json()) as { data?: Array<{ id: string }> };
|
||||||
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
|
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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
|
getOpenAICompatProxyDir,
|
||||||
getLegacyOpenAICompatProxySessionPath,
|
getLegacyOpenAICompatProxySessionPath,
|
||||||
getOpenAICompatProxyPidPath,
|
getOpenAICompatProxyPidPath,
|
||||||
getOpenAICompatProxySessionPath,
|
getOpenAICompatProxySessionPath,
|
||||||
} from '../../../src/proxy/proxy-daemon-paths';
|
} 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 originalCcsHome: string | undefined;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -51,3 +56,24 @@ describe('listOpenAICompatProxyProfileNames', () => {
|
|||||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
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