From 9e2fd096e4a30c29a9c909284234d129a577b853 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 21:27:51 -0500 Subject: [PATCH 01/11] feat(cliproxy): add proxy target resolver for remote/local routing - add ProxyTarget interface with host, port, protocol, authToken, isRemote - implement getProxyTarget() to resolve target from unified config - add buildProxyUrl() and buildProxyHeaders() utilities - handle optional auth token (empty = no Authorization header) --- src/cliproxy/proxy-target-resolver.ts | 93 +++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/cliproxy/proxy-target-resolver.ts diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts new file mode 100644 index 00000000..e87d53ff --- /dev/null +++ b/src/cliproxy/proxy-target-resolver.ts @@ -0,0 +1,93 @@ +/** + * Proxy Target Resolver + * + * Determines whether CLIProxyAPI requests should go to local or remote + * based on unified config. Used by stats-fetcher, auth-routes, and UI. + */ + +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import type { CliproxyServerConfig } from '../config/unified-config-types'; + +/** Default CLIProxyAPI port */ +const DEFAULT_CLIPROXY_PORT = 8317; + +/** Resolved proxy target for making requests */ +export interface ProxyTarget { + /** Target hostname or IP */ + host: string; + /** Target port */ + port: number; + /** Protocol (http/https) */ + protocol: 'http' | 'https'; + /** Optional auth token - only send header if defined and non-empty */ + authToken?: string; + /** True if targeting remote server, false if local */ + isRemote: boolean; +} + +/** + * Load cliproxy_server configuration from unified config. + * Returns undefined if not configured. + */ +function loadCliproxyServerConfig(): CliproxyServerConfig | undefined { + const config = loadOrCreateUnifiedConfig(); + return config.cliproxy_server; +} + +/** + * Get the current CLIProxyAPI target based on unified config. + * Returns remote server config if enabled, otherwise localhost. + */ +export function getProxyTarget(): ProxyTarget { + const config = loadCliproxyServerConfig(); + + if (config?.remote?.enabled && config.remote?.host) { + return { + host: config.remote.host, + port: config.remote.port ?? DEFAULT_CLIPROXY_PORT, + protocol: config.remote.protocol ?? 'http', + authToken: config.remote.auth_token || undefined, // Empty string -> undefined + isRemote: true, + }; + } + + return { + host: '127.0.0.1', + port: config?.local?.port ?? DEFAULT_CLIPROXY_PORT, + protocol: 'http', + isRemote: false, + }; +} + +/** + * Build URL for proxy endpoint + * @param target Resolved proxy target + * @param path Endpoint path (e.g., '/v0/management/usage') + */ +export function buildProxyUrl(target: ProxyTarget, path: string): string { + return `${target.protocol}://${target.host}:${target.port}${path}`; +} + +/** + * Build request headers for proxy requests + * Handles optional auth token - only adds Authorization header if token is set. + * + * @param target Resolved proxy target + * @param additionalHeaders Extra headers to merge + */ +export function buildProxyHeaders( + target: ProxyTarget, + additionalHeaders: Record = {} +): Record { + const headers: Record = { + Accept: 'application/json', + ...additionalHeaders, + }; + + // Only add auth header if token is configured + if (target.authToken) { + headers['Authorization'] = `Bearer ${target.authToken}`; + } + + return headers; +} From 17bb6f9836a56eddcb5e683e9d8f3d262f48d0cd Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 21:29:20 -0500 Subject: [PATCH 02/11] feat(cliproxy): add remote routing for stats and auth endpoints - Refactor stats-fetcher.ts: 5 functions now use getProxyTarget() - Add remote-auth-fetcher.ts: fetch auth status from remote /v0/management/auth-files - Update cliproxy-auth-routes.ts: branch on isRemote for GET routes - Return 501 for account management in remote mode (unsupported) --- src/cliproxy/remote-auth-fetcher.ts | 150 ++++++++++++++++++ src/cliproxy/stats-fetcher.ts | 119 +++++++++----- src/web-server/routes/cliproxy-auth-routes.ts | 67 +++++++- 3 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 src/cliproxy/remote-auth-fetcher.ts diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts new file mode 100644 index 00000000..1c5cb01a --- /dev/null +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -0,0 +1,150 @@ +/** + * Remote Auth Fetcher + * Fetches and transforms auth data from remote CLIProxyAPI. + */ + +import { + getProxyTarget, + buildProxyUrl, + buildProxyHeaders, + ProxyTarget, +} from './proxy-target-resolver'; + +/** Remote auth file from CLIProxyAPI /v0/management/auth-files */ +interface RemoteAuthFile { + id: string; + name: string; + type: string; + provider: string; + email?: string; + status: 'active' | 'disabled' | 'unavailable'; + source: 'file' | 'memory'; +} + +/** Response from CLIProxyAPI auth-files endpoint */ +interface RemoteAuthFilesResponse { + files: RemoteAuthFile[]; +} + +/** Account info for UI display */ +export interface RemoteAccountInfo { + id: string; + email: string; + isDefault: boolean; + status: 'active' | 'disabled' | 'unavailable'; +} + +/** Auth status for a provider (UI format) */ +export interface RemoteAuthStatus { + provider: string; + displayName: string; + authenticated: boolean; + lastAuth: string | null; + tokenFiles: number; + accounts: RemoteAccountInfo[]; + defaultAccount: string | null; + source: 'remote'; +} + +/** Map CLIProxyAPI provider names to CCS internal names */ +const PROVIDER_MAP: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** Display names for providers */ +const PROVIDER_DISPLAY_NAMES: Record = { + gemini: 'Google Gemini', + agy: 'AntiGravity', + codex: 'Codex', + qwen: 'Qwen', + iflow: 'iFlow', +}; + +/** + * Fetch auth status from remote CLIProxyAPI + * @throws Error if remote is unreachable or returns error + */ +export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise { + const proxyTarget = target ?? getProxyTarget(); + + if (!proxyTarget.isRemote) { + throw new Error('fetchRemoteAuthStatus called but remote mode not enabled'); + } + + const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: buildProxyHeaders(proxyTarget), + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error('Authentication failed - check auth token in settings'); + } + throw new Error(`Remote returned ${response.status}: ${response.statusText}`); + } + + const data = (await response.json()) as RemoteAuthFilesResponse; + return transformRemoteAuthFiles(data.files); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Remote proxy connection timed out'); + } + throw error; + } +} + +/** Transform CLIProxyAPI auth files to CCS AuthStatus format */ +function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { + const byProvider = new Map(); + + for (const file of files) { + const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + if (!provider) continue; + + const existing = byProvider.get(provider); + if (existing) { + existing.push(file); + } else { + byProvider.set(provider, [file]); + } + } + + const result: RemoteAuthStatus[] = []; + + Array.from(byProvider.entries()).forEach(([provider, providerFiles]) => { + const activeFiles = providerFiles.filter((f) => f.status === 'active'); + const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({ + id: f.id, + email: f.email || f.name, + isDefault: idx === 0, + status: f.status, + })); + + result.push({ + provider, + displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + authenticated: activeFiles.length > 0, + lastAuth: null, + tokenFiles: providerFiles.length, + accounts, + defaultAccount: accounts.find((a) => a.isDefault)?.id || null, + source: 'remote', + }); + }); + + return result; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index e7506a74..f66e987d 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -5,7 +5,8 @@ * Requires usage-statistics-enabled: true in config.yaml. */ -import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { CCS_CONTROL_PANEL_SECRET } from './config-generator'; +import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -95,19 +96,27 @@ interface UsageApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Stats object or null if unavailable */ -export async function fetchCliproxyStats( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyStats(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout - const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/usage'); + + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, + headers, }); clearTimeout(timeoutId); @@ -222,20 +231,27 @@ export interface CliproxyModelsResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Categorized models or null if unavailable */ -export async function fetchCliproxyModels( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyModels(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`http://127.0.0.1:${port}/v1/models`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v1/models'); + + // For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - // Use the internal API key for /v1 endpoints - Authorization: 'Bearer ccs-internal-managed', - }, + headers, }); clearTimeout(timeoutId); @@ -293,19 +309,27 @@ interface ErrorLogsApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Array of error log metadata or null if unavailable */ -export async function fetchCliproxyErrorLogs( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyErrorLogs(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/request-error-logs'); + + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, + headers, }); clearTimeout(timeoutId); @@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs( */ export async function fetchCliproxyErrorLogContent( name: string, - port: number = CLIPROXY_DEFAULT_PORT + port?: number ): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch( - `http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`, - { - signal: controller.signal, - headers: { - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, - } + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl( + target, + `/v0/management/request-error-logs/${encodeURIComponent(name)}` ); + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + clearTimeout(timeoutId); if (!response.ok) { @@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent( * @param port CLIProxyAPI port (default: 8317) * @returns true if proxy is running */ -export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise { +export async function isCliproxyRunning(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout - // Use root endpoint - CLIProxyAPI returns server info at / - const response = await fetch(`http://127.0.0.1:${port}/`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/'); + + // Health check - no auth needed for root endpoint + const response = await fetch(url, { signal: controller.signal, }); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 6d5d16b2..8302a053 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -21,6 +21,8 @@ import { removeAccount as removeAccountFn, touchAccount, } from '../../cliproxy/account-manager'; +import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -33,7 +35,24 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers */ router.get('/', async (_req: Request, res: Response) => { - // Initialize accounts from existing tokens on first request + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + try { + const authStatus = await fetchRemoteAuthStatus(target); + res.json({ authStatus, source: 'remote' }); + return; + } catch (error) { + res.status(503).json({ + error: (error as Error).message, + authStatus: [], + source: 'remote', + }); + return; + } + } + + // Local mode: Initialize accounts from existing tokens on first request initializeAccounts(); // Fetch CLIProxyAPI usage stats to determine active providers @@ -90,8 +109,32 @@ router.get('/', async (_req: Request, res: Response) => { /** * GET /api/cliproxy/accounts - Get all accounts across all providers */ -router.get('/accounts', (_req: Request, res: Response) => { - // Initialize accounts from existing tokens +router.get('/accounts', async (_req: Request, res: Response) => { + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + try { + const authStatus = await fetchRemoteAuthStatus(target); + // Transform RemoteAuthStatus[] to account summary format + const accounts = authStatus.flatMap((status) => + status.accounts.map((acc) => ({ + provider: status.provider, + ...acc, + })) + ); + res.json({ accounts, source: 'remote' }); + return; + } catch (error) { + res.status(503).json({ + error: (error as Error).message, + accounts: [], + source: 'remote', + }); + return; + } + } + + // Local mode: Initialize accounts from existing tokens initializeAccounts(); const accounts = getAllAccountsSummary(); @@ -118,6 +161,15 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { * POST /api/cliproxy/accounts/:provider/default - Set default account for provider */ router.post('/accounts/:provider/default', (req: Request, res: Response): void => { + // Check if remote mode is enabled - account management not available + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Account management not available in remote mode', + }); + return; + } + const { provider } = req.params; const { accountId } = req.body; @@ -145,6 +197,15 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = * DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account */ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => { + // Check if remote mode is enabled - account management not available + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Account management not available in remote mode', + }); + return; + } + const { provider, accountId } = req.params; // Validate provider From bfa55e041cb33b689d95b492abd637a98eab5b42 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 21:29:55 -0500 Subject: [PATCH 03/11] feat(ui): dynamic control panel embed for remote CLIProxy - Use React Query to fetch cliproxy_server config - Build URLs dynamically based on remote/local mode - Add Globe icon and Remote badge indicator - Show context-aware error messages for remote failures --- .../cliproxy/control-panel-embed.tsx | 94 ++++++++++++++++--- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 393a9e64..90636e7a 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -3,10 +3,14 @@ * * Embeds the CLIProxy management.html with auto-authentication. * Uses postMessage to inject credentials into the iframe. + * Supports both local and remote CLIProxy server connections. */ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import type { CliproxyServerConfig } from '@/lib/api-client'; /** CLIProxyAPI default port */ const CLIPROXY_DEFAULT_PORT = 8317; @@ -25,13 +29,52 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const [isConnected, setIsConnected] = useState(false); const [showLoginHint, setShowLoginHint] = useState(true); - const managementUrl = `http://localhost:${port}/management.html`; + // Fetch cliproxy_server config for remote/local mode detection + const { data: cliproxyConfig } = useQuery({ + queryKey: ['cliproxy-server-config'], + queryFn: () => api.cliproxyServer.get(), + staleTime: 30000, // 30 seconds + }); + + // Calculate URLs and settings based on remote or local mode + const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => { + const remote = cliproxyConfig?.remote; + + if (remote?.enabled && remote?.host) { + const protocol = remote.protocol || 'http'; + // Use port from config, or default based on protocol (443 for https, 80 for http) + const remotePort = remote.port || (protocol === 'https' ? 443 : 80); + // Only include port in URL if it's non-standard + const portSuffix = + (protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80) + ? '' + : `:${remotePort}`; + const baseUrl = `${protocol}://${remote.host}${portSuffix}`; + + return { + managementUrl: `${baseUrl}/management.html`, + checkUrl: `${baseUrl}/`, + authToken: remote.auth_token || undefined, + isRemote: true, + displayHost: `${remote.host}${portSuffix}`, + }; + } + + // Local mode + return { + managementUrl: `http://localhost:${port}/management.html`, + checkUrl: `http://localhost:${port}/`, + authToken: CCS_CONTROL_PANEL_SECRET, + isRemote: false, + displayHost: `localhost:${port}`, + }; + }, [cliproxyConfig, port]); // Check if CLIProxy is running useEffect(() => { const checkConnection = async () => { try { - const response = await fetch(`http://localhost:${port}/`, { + const response = await fetch(checkUrl, { signal: AbortSignal.timeout(2000), }); if (response.ok) { @@ -39,16 +82,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel setError(null); } else { setIsConnected(false); - setError('CLIProxy returned an error'); + setError( + isRemote + ? `Remote CLIProxy at ${displayHost} returned an error` + : 'CLIProxy returned an error' + ); } } catch { setIsConnected(false); - setError('CLIProxy is not running'); + setError( + isRemote + ? `Remote CLIProxy at ${displayHost} is not reachable` + : 'CLIProxy is not running' + ); } }; checkConnection(); - }, [port]); + }, [checkUrl, isRemote, displayHost]); // Handle iframe load - attempt to auto-login via postMessage const handleIframeLoad = useCallback(() => { @@ -57,23 +108,25 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Try to inject credentials via postMessage // The management.html needs to listen for this message // If it doesn't support it, user will see the login page - if (iframeRef.current?.contentWindow) { + if (iframeRef.current?.contentWindow && authToken) { try { + // Derive apiBase from checkUrl (remove trailing slash) + const apiBase = checkUrl.replace(/\/$/, ''); // Send credentials to iframe iframeRef.current.contentWindow.postMessage( { type: 'ccs-auto-login', - apiBase: `http://localhost:${port}`, - managementKey: CCS_CONTROL_PANEL_SECRET, + apiBase, + managementKey: authToken, }, - `http://localhost:${port}` + apiBase ); } catch { // Cross-origin restriction - expected if not same origin console.debug('[ControlPanelEmbed] postMessage failed - cross-origin'); } } - }, [port]); + }, [checkUrl, authToken]); const handleRefresh = () => { setIsLoading(true); @@ -119,15 +172,22 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel return (
- {/* Login hint banner */} + {/* Remote indicator and login hint banner */} {showLoginHint && !isLoading && (
+ {isRemote && ( + <> + + Remote + | + + )} Key:{' '} - ccs + {authToken || 'ccs'}
)} From cdb465342e6461cd7ff36f59f2d3873e50092210 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 20 Dec 2025 21:43:22 -0500 Subject: [PATCH 04/11] fix(cliproxy): address code review findings for remote routing - Add runtime API response validation in remote-auth-fetcher - Add AbortController cleanup to prevent state updates after unmount - Validate postMessage origin matches iframe src before sending creds - Mask auth token display (show ***last4) for security - Reset error/connected state on refresh - Fix port default to use protocol-based (443 for https, 80 for http) - Normalize path in buildProxyUrl to ensure leading slash - Add fallback for undefined email (default to 'Unknown') - Extract timeout constant (REMOTE_FETCH_TIMEOUT_MS) - Remove unused RemoteAuthFilesResponse interface --- src/cliproxy/proxy-target-resolver.ts | 13 ++++-- src/cliproxy/remote-auth-fetcher.ts | 38 +++++++++------- .../cliproxy/control-panel-embed.tsx | 43 ++++++++++++++++--- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index e87d53ff..64778f44 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -42,10 +42,15 @@ export function getProxyTarget(): ProxyTarget { const config = loadCliproxyServerConfig(); if (config?.remote?.enabled && config.remote?.host) { + const protocol = config.remote.protocol ?? 'http'; + // Default port based on protocol if not specified + const defaultPort = protocol === 'https' ? 443 : 80; + const port = config.remote.port ?? defaultPort; + return { host: config.remote.host, - port: config.remote.port ?? DEFAULT_CLIPROXY_PORT, - protocol: config.remote.protocol ?? 'http', + port, + protocol, authToken: config.remote.auth_token || undefined, // Empty string -> undefined isRemote: true, }; @@ -65,7 +70,9 @@ export function getProxyTarget(): ProxyTarget { * @param path Endpoint path (e.g., '/v0/management/usage') */ export function buildProxyUrl(target: ProxyTarget, path: string): string { - return `${target.protocol}://${target.host}:${target.port}${path}`; + // Normalize path to ensure leading slash + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`; } /** diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 1c5cb01a..3f11d410 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -10,6 +10,9 @@ import { ProxyTarget, } from './proxy-target-resolver'; +/** Timeout for remote fetch requests (ms) */ +const REMOTE_FETCH_TIMEOUT_MS = 5000; + /** Remote auth file from CLIProxyAPI /v0/management/auth-files */ interface RemoteAuthFile { id: string; @@ -21,11 +24,6 @@ interface RemoteAuthFile { source: 'file' | 'memory'; } -/** Response from CLIProxyAPI auth-files endpoint */ -interface RemoteAuthFilesResponse { - files: RemoteAuthFile[]; -} - /** Account info for UI display */ export interface RemoteAccountInfo { id: string; @@ -39,7 +37,6 @@ export interface RemoteAuthStatus { provider: string; displayName: string; authenticated: boolean; - lastAuth: string | null; tokenFiles: number; accounts: RemoteAccountInfo[]; defaultAccount: string | null; @@ -78,7 +75,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise controller.abort(), 5000); + const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS); try { const response = await fetch(url, { @@ -95,8 +92,14 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise(); for (const file of files) { const provider = PROVIDER_MAP[file.provider.toLowerCase()]; - if (!provider) continue; + if (!provider) { + // Unknown provider, skip (could add logging in debug mode) + continue; + } const existing = byProvider.get(provider); if (existing) { @@ -125,11 +134,11 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { const result: RemoteAuthStatus[] = []; - Array.from(byProvider.entries()).forEach(([provider, providerFiles]) => { + for (const [provider, providerFiles] of byProvider) { const activeFiles = providerFiles.filter((f) => f.status === 'active'); const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({ id: f.id, - email: f.email || f.name, + email: f.email || f.name || 'Unknown', isDefault: idx === 0, status: f.status, })); @@ -138,13 +147,12 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { provider, displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, authenticated: activeFiles.length > 0, - lastAuth: null, tokenFiles: providerFiles.length, accounts, defaultAccount: accounts.find((a) => a.isDefault)?.id || null, source: 'remote', }); - }); + } return result; } diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 90636e7a..2abe97f2 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -30,12 +30,19 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const [showLoginHint, setShowLoginHint] = useState(true); // Fetch cliproxy_server config for remote/local mode detection - const { data: cliproxyConfig } = useQuery({ + const { data: cliproxyConfig, error: configError } = useQuery({ queryKey: ['cliproxy-server-config'], queryFn: () => api.cliproxyServer.get(), staleTime: 30000, // 30 seconds }); + // Log config fetch errors (fallback to local mode on error) + useEffect(() => { + if (configError) { + console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError); + } + }, [configError]); + // Calculate URLs and settings based on remote or local mode const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => { const remote = cliproxyConfig?.remote; @@ -72,10 +79,12 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Check if CLIProxy is running useEffect(() => { + const controller = new AbortController(); + const checkConnection = async () => { try { const response = await fetch(checkUrl, { - signal: AbortSignal.timeout(2000), + signal: controller.signal, }); if (response.ok) { setIsConnected(true); @@ -88,7 +97,10 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel : 'CLIProxy returned an error' ); } - } catch { + } catch (e) { + // Ignore abort errors (component unmounting) + if (e instanceof Error && e.name === 'AbortError') return; + setIsConnected(false); setError( isRemote @@ -98,7 +110,12 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } }; - checkConnection(); + // Start connection check with timeout + const timeoutId = setTimeout(() => controller.abort(), 2000); + checkConnection().finally(() => clearTimeout(timeoutId)); + + // Cleanup: abort fetch on unmount + return () => controller.abort(); }, [checkUrl, isRemote, displayHost]); // Handle iframe load - attempt to auto-login via postMessage @@ -112,6 +129,14 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel try { // Derive apiBase from checkUrl (remove trailing slash) const apiBase = checkUrl.replace(/\/$/, ''); + + // Security: Validate iframe src matches target origin before sending credentials + const iframeSrc = iframeRef.current.src; + if (!iframeSrc.startsWith(apiBase)) { + console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); + return; + } + // Send credentials to iframe iframeRef.current.contentWindow.postMessage( { @@ -121,15 +146,17 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }, apiBase ); - } catch { + } catch (e) { // Cross-origin restriction - expected if not same origin - console.debug('[ControlPanelEmbed] postMessage failed - cross-origin'); + console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); } } }, [checkUrl, authToken]); const handleRefresh = () => { setIsLoading(true); + setError(null); + setIsConnected(false); if (iframeRef.current) { iframeRef.current.src = managementUrl; } @@ -187,7 +214,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel Key:{' '} - {authToken || 'ccs'} + {authToken && authToken.length > 4 + ? `***${authToken.slice(-4)}` + : authToken || 'ccs'}