diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index dd72b261..e895d251 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -51,6 +51,7 @@ import { import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -126,7 +127,25 @@ export async function execClaudeWithCLIProxy( // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) // This filters proxy flags from args and returns resolved config - const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args); + const unifiedConfig = loadOrCreateUnifiedConfig(); + const cliproxyServerConfig = unifiedConfig.cliproxy_server; + const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { + remote: cliproxyServerConfig?.remote + ? { + enabled: cliproxyServerConfig.remote.enabled, + host: cliproxyServerConfig.remote.host, + port: cliproxyServerConfig.remote.port, + protocol: cliproxyServerConfig.remote.protocol, + auth_token: cliproxyServerConfig.remote.auth_token, + } + : undefined, + local: cliproxyServerConfig?.local + ? { + port: cliproxyServerConfig.local.port, + auto_start: cliproxyServerConfig.local.auto_start, + } + : undefined, + }); // Use resolved port from proxy config (overrides ExecutorConfig) if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { @@ -516,14 +535,29 @@ export async function execClaudeWithCLIProxy( // 7. Execute Claude CLI with proxied environment // Use remote or local env vars based on mode + // When remote is configured (even if using local), pass config for URL rewriting + const remoteRewriteConfig = + proxyConfig.mode === 'remote' && proxyConfig.host + ? { + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + } + : undefined; + const envVars = useRemoteProxy - ? getRemoteEnvVars(provider, { - host: proxyConfig.host ?? 'localhost', - port: proxyConfig.port, - protocol: proxyConfig.protocol, - authToken: proxyConfig.authToken, - }) - : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); + ? getRemoteEnvVars( + provider, + { + host: proxyConfig.host ?? 'localhost', + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + }, + cfg.customSettingsPath + ) + : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig); const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index d0ca4b95..57022dcd 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -392,6 +392,48 @@ function getGlobalEnvVars(): Record { return globalEnvConfig.env; } +/** Remote proxy configuration for URL rewriting */ +interface RemoteProxyRewriteConfig { + host: string; + port?: number; + protocol: 'http' | 'https'; + authToken?: string; +} + +/** + * Rewrite localhost URLs to remote server URLs. + * Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0 + */ +function rewriteLocalhostUrls( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + remoteConfig: RemoteProxyRewriteConfig +): NodeJS.ProcessEnv { + const result = { ...envVars }; + const baseUrl = result.ANTHROPIC_BASE_URL; + + if (!baseUrl) return result; + + // Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0) + const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i; + if (!localhostPattern.test(baseUrl)) return result; + + // Build remote URL with smart port handling + const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; + const effectivePort = remoteConfig.port ?? defaultPort; + const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; + const remoteBaseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; + + result.ANTHROPIC_BASE_URL = remoteBaseUrl; + + // Update auth token if provided + if (remoteConfig.authToken) { + result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken; + } + + return result; +} + /** * Get effective environment variables for provider * @@ -402,15 +444,20 @@ function getGlobalEnvVars(): Record { * * All results are merged with global_env vars (telemetry/reporting disables). * User takes full responsibility for custom settings. + * + * If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server. */ export function getEffectiveEnvVars( provider: CLIProxyProvider, port: number = CLIPROXY_DEFAULT_PORT, - customSettingsPath?: string + customSettingsPath?: string, + remoteRewriteConfig?: RemoteProxyRewriteConfig ): NodeJS.ProcessEnv { // Get global env vars (DISABLE_TELEMETRY, etc.) const globalEnv = getGlobalEnvVars(); + let envVars: NodeJS.ProcessEnv; + // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); @@ -421,7 +468,12 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Custom variant settings found - merge with global env - return { ...globalEnv, ...settings.env }; + envVars = { ...globalEnv, ...settings.env }; + // Apply remote rewrite if configured + if (remoteRewriteConfig) { + envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + } + return envVars; } } catch { // Invalid JSON - fall through to provider defaults @@ -443,7 +495,12 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // User override found - merge with global env - return { ...globalEnv, ...settings.env }; + envVars = { ...globalEnv, ...settings.env }; + // Apply remote rewrite if configured + if (remoteRewriteConfig) { + envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + } + return envVars; } } catch { // Invalid JSON or structure - fall through to defaults @@ -483,48 +540,89 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { /** * Get environment variables for remote proxy mode. * Uses the remote proxy's provider endpoint as the base URL. + * Respects user model settings from custom settings path or provider settings file. * * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) * @param remoteConfig Remote proxy connection details + * @param customSettingsPath Optional path to user's custom settings file * @returns Environment variables for Claude CLI */ export function getRemoteEnvVars( provider: CLIProxyProvider, - remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string } + remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }, + customSettingsPath?: string ): Record { // Build URL with smart port handling - omit if using protocol default const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; const effectivePort = remoteConfig.port ?? defaultPort; const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; - const models = getModelMapping(provider); // Get global env vars (DISABLE_TELEMETRY, etc.) const globalEnv = getGlobalEnvVars(); - // Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.) - const baseEnvVars = getEnvVarsFromConfig(provider); + // Load user settings with priority: custom path > user settings file > base config + let userEnvVars: Record = {}; - // Filter out core env vars from base config to avoid conflicts - const { - ANTHROPIC_BASE_URL: _baseUrl, - ANTHROPIC_AUTH_TOKEN: _authToken, - ANTHROPIC_MODEL: _model, - ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel, - ...additionalEnvVars - } = baseEnvVars; + // Priority 1: Custom settings path (for user-defined variants) + if (customSettingsPath) { + const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); + if (fs.existsSync(expandedPath)) { + try { + const content = fs.readFileSync(expandedPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + userEnvVars = settings.env as Record; + } + } catch { + // Invalid JSON - fall through to provider defaults + console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); + } + } + } + // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) + if (Object.keys(userEnvVars).length === 0) { + const settingsPath = getProviderSettingsPath(provider); + if (fs.existsSync(settingsPath)) { + try { + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + userEnvVars = settings.env as Record; + } + } catch { + // Invalid JSON - fall through to base config + } + } + } + + // Priority 3: Base config defaults + if (Object.keys(userEnvVars).length === 0) { + const models = getModelMapping(provider); + const baseEnvVars = getEnvVarsFromConfig(provider); + // Filter out URL/auth from base config (we'll set those from remote config) + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ...additionalEnvVars + } = baseEnvVars; + userEnvVars = { + ...additionalEnvVars, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + } + + // Build final env: global + user settings + remote URL/auth override const env: Record = { ...globalEnv, - ...additionalEnvVars, + ...userEnvVars, + // Always override URL and auth token with remote config ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY, - ANTHROPIC_MODEL: models.claudeModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, }; return env; diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts new file mode 100644 index 00000000..64778f44 --- /dev/null +++ b/src/cliproxy/proxy-target-resolver.ts @@ -0,0 +1,100 @@ +/** + * 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) { + 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, + protocol, + 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 { + // Normalize path to ensure leading slash + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`; +} + +/** + * 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; +} diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts new file mode 100644 index 00000000..5acaba86 --- /dev/null +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -0,0 +1,159 @@ +/** + * Remote Auth Fetcher + * Fetches and transforms auth data from remote CLIProxyAPI. + */ + +import { + getProxyTarget, + buildProxyUrl, + buildProxyHeaders, + 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; + name: string; + type: string; + provider: string; + email?: string; + status: 'active' | 'disabled' | 'unavailable'; + source: 'file' | 'memory'; +} + +/** 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; + tokenFiles: number; + accounts: RemoteAccountInfo[]; + defaultAccount: string | null; + source: 'remote'; +} + +/** Map CLIProxyAPI provider names to CCS internal names */ +const PROVIDER_MAP: Record = { + gemini: 'gemini', + 'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth + 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(), REMOTE_FETCH_TIMEOUT_MS); + + 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: unknown = await response.json(); + + // Validate response structure + if (!data || typeof data !== 'object' || !('files' in data) || !Array.isArray(data.files)) { + throw new Error('Invalid response format from remote auth endpoint'); + } + + return transformRemoteAuthFiles(data.files as RemoteAuthFile[]); + } 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 + * @param files Array of auth files from remote API + */ +function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { + const byProvider = new Map(); + + for (const file of files) { + const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + if (!provider) { + // Unknown provider, skip (could add logging in debug mode) + continue; + } + + const existing = byProvider.get(provider); + if (existing) { + existing.push(file); + } else { + byProvider.set(provider, [file]); + } + } + + const result: RemoteAuthStatus[] = []; + + 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 || 'Unknown', + isDefault: idx === 0, + status: f.status, + })); + + result.push({ + provider, + displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + authenticated: activeFiles.length > 0, + 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 5f24ac0a..feeff110 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(); @@ -34,7 +36,15 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i */ router.get('/', async (_req: Request, res: Response): Promise => { try { - // Initialize accounts from existing tokens on first request + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + const authStatus = await fetchRemoteAuthStatus(target); + res.json({ authStatus, source: 'remote' }); + return; + } + + // Local mode: Initialize accounts from existing tokens on first request initializeAccounts(); // Fetch CLIProxyAPI usage stats to determine active providers @@ -85,7 +95,17 @@ router.get('/', async (_req: Request, res: Response): Promise => { res.json({ authStatus }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + // Return appropriate error for remote vs local mode + const target = getProxyTarget(); + if (target.isRemote) { + res.status(503).json({ + error: (error as Error).message, + authStatus: [], + source: 'remote', + }); + } else { + res.status(500).json({ error: (error as Error).message }); + } } }); @@ -94,16 +114,40 @@ router.get('/', async (_req: Request, res: Response): Promise => { /** * GET /api/cliproxy/accounts - Get all accounts across all providers */ -router.get('/accounts', (_req: Request, res: Response) => { +router.get('/accounts', async (_req: Request, res: Response): Promise => { try { - // Initialize accounts from existing tokens + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + 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; + } + + // Local mode: Initialize accounts from existing tokens initializeAccounts(); const accounts = getAllAccountsSummary(); res.json({ accounts }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to list accounts'; - res.status(500).json({ error: message }); + const target = getProxyTarget(); + if (target.isRemote) { + res.status(503).json({ + error: (error as Error).message, + accounts: [], + source: 'remote', + }); + } else { + const message = error instanceof Error ? error.message : 'Failed to list accounts'; + res.status(500).json({ error: message }); + } } }); @@ -132,6 +176,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; @@ -166,6 +219,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 diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 393a9e64..2abe97f2 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,30 +29,94 @@ 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, 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; + + 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 controller = new AbortController(); + const checkConnection = async () => { try { - const response = await fetch(`http://localhost:${port}/`, { - signal: AbortSignal.timeout(2000), + const response = await fetch(checkUrl, { + signal: controller.signal, }); if (response.ok) { setIsConnected(true); setError(null); } else { setIsConnected(false); - setError('CLIProxy returned an error'); + setError( + isRemote + ? `Remote CLIProxy at ${displayHost} returned an error` + : 'CLIProxy returned an error' + ); } - } catch { + } catch (e) { + // Ignore abort errors (component unmounting) + if (e instanceof Error && e.name === 'AbortError') return; + setIsConnected(false); - setError('CLIProxy is not running'); + setError( + isRemote + ? `Remote CLIProxy at ${displayHost} is not reachable` + : 'CLIProxy is not running' + ); } }; - checkConnection(); - }, [port]); + // 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 const handleIframeLoad = useCallback(() => { @@ -57,26 +125,38 @@ 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(/\/$/, ''); + + // 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( { type: 'ccs-auto-login', - apiBase: `http://localhost:${port}`, - managementKey: CCS_CONTROL_PANEL_SECRET, + apiBase, + managementKey: authToken, }, - `http://localhost:${port}` + 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); } } - }, [port]); + }, [checkUrl, authToken]); const handleRefresh = () => { setIsLoading(true); + setError(null); + setIsConnected(false); if (iframeRef.current) { iframeRef.current.src = managementUrl; } @@ -119,15 +199,24 @@ 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 && authToken.length > 4 + ? `***${authToken.slice(-4)}` + : authToken || 'ccs'}
)} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 2d9f14ff..a153dbbd 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -31,6 +31,7 @@ export function ProviderEditor({ authStatus, catalog, logoProvider, + isRemoteMode, onAddAccount, onSetDefault, onRemoveAccount, @@ -124,6 +125,7 @@ export function ProviderEditor({ hasChanges={hasChanges} isRawJsonValid={isRawJsonValid} isSaving={saveMutation.isPending} + isRemoteMode={isRemoteMode} onRefetch={refetch} onSave={() => saveMutation.mutate()} /> diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx index f64f9605..3d662811 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Save, Loader2, RefreshCw } from 'lucide-react'; +import { Save, Loader2, RefreshCw, Globe } from 'lucide-react'; import { ProviderLogo } from '../provider-logo'; import type { SettingsResponse } from './types'; @@ -18,6 +18,7 @@ interface ProviderEditorHeaderProps { hasChanges: boolean; isRawJsonValid: boolean; isSaving: boolean; + isRemoteMode?: boolean; onRefetch: () => void; onSave: () => void; } @@ -31,6 +32,7 @@ export function ProviderEditorHeader({ hasChanges, isRawJsonValid, isSaving, + isRemoteMode, onRefetch, onSave, }: ProviderEditorHeaderProps) { @@ -41,16 +43,31 @@ export function ProviderEditorHeader({

{displayName}

- {data?.path && ( + {isRemoteMode && ( + + + Remote + + )} + {!isRemoteMode && data?.path && ( - {data.path.replace(/^.*\//, '')} + {data.path.replace(/^.*[\\/]/, '')} )}
- {data && ( -

- Last modified: {new Date(data.mtime).toLocaleString()} + {isRemoteMode ? ( +

+ Traffic auto-routed to remote server

+ ) : ( + data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ ) )}
diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 4d1d87a6..6bea9288 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -21,6 +21,8 @@ export interface ProviderEditorProps { catalog?: ProviderCatalog; /** Provider type for logo display (defaults to provider) */ logoProvider?: string; + /** True if using remote CLIProxy mode (hides local paths) */ + isRemoteMode?: boolean; onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index bfa0a2c3..6c4af3b0 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -3,11 +3,24 @@ * * Displays CLIProxy process status with start/stop/restart controls. * Shows: running state, port, session count, uptime, update availability. + * In remote mode: shows remote server info instead of local controls. */ -import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react'; +import { + Activity, + Power, + RefreshCw, + Clock, + Users, + Square, + RotateCw, + ArrowUp, + Globe, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { useQuery } from '@tanstack/react-query'; +import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { useProxyStatus, useStartProxy, @@ -48,10 +61,32 @@ export function ProxyStatusWidget() { const startProxy = useStartProxy(); const stopProxy = useStopProxy(); + // Fetch cliproxy_server config for remote mode detection + const { data: cliproxyConfig } = useQuery({ + queryKey: ['cliproxy-server-config'], + queryFn: () => api.cliproxyServer.get(), + staleTime: 30000, // 30 seconds + }); + + // Determine if remote mode is enabled + const remoteConfig = cliproxyConfig?.remote; + const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host; + const isRunning = status?.running ?? false; const isActioning = startProxy.isPending || stopProxy.isPending; const hasUpdate = updateCheck?.hasUpdate ?? false; + // Build remote display info + const remoteDisplayHost = isRemoteMode + ? (() => { + const protocol = remoteConfig.protocol || 'http'; + const port = remoteConfig.port || (protocol === 'https' ? 443 : 80); + const isDefaultPort = + (protocol === 'https' && port === 443) || (protocol === 'http' && port === 80); + return isDefaultPort ? remoteConfig.host : `${remoteConfig.host}:${port}`; + })() + : null; + // Restart = stop then start const handleRestart = async () => { await stopProxy.mutateAsync(); @@ -60,6 +95,43 @@ export function ProxyStatusWidget() { startProxy.mutate(); }; + // Remote mode: show remote server info + if (isRemoteMode) { + return ( +
+
+
+ + Remote Proxy + + Active + +
+ +
+ +
+
+ {remoteDisplayHost} +
+

+ Traffic auto-routed to remote server +

+
+
+ ); + } + + // Local mode: show original controls + return (
request<{ variants: Variant[] }>('/cliproxy'), - getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'), + getAuthStatus: () => + request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'), create: (data: CreateVariant) => request('/cliproxy', { method: 'POST', diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 46f668ac..b9fc21a9 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -192,6 +192,7 @@ export function CliproxyPage() { } | null>(null); const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); + const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); // Auto-select first provider if nothing selected @@ -338,6 +339,7 @@ export function CliproxyPage() { authStatus={parentAuthForVariant} catalog={MODEL_CATALOGS[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} + isRemoteMode={isRemoteMode} onAddAccount={() => setAddAccountProvider({ provider: selectedVariantData.provider, @@ -365,6 +367,7 @@ export function CliproxyPage() { displayName={selectedStatus.displayName} authStatus={selectedStatus} catalog={MODEL_CATALOGS[selectedStatus.provider]} + isRemoteMode={isRemoteMode} onAddAccount={() => setAddAccountProvider({ provider: selectedStatus.provider,