diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts new file mode 100644 index 00000000..c4c8a321 --- /dev/null +++ b/src/cliproxy/remote-proxy-client.ts @@ -0,0 +1,236 @@ +/** + * Remote Proxy Client for CLIProxyAPI + * + * HTTP client for health checks and connection testing against remote CLIProxyAPI instances. + * Uses native fetch API with aggressive timeout for CLI responsiveness. + */ + +import * as https from 'https'; + +/** Error codes for remote proxy status */ +export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; + +/** Status returned from remote proxy health check */ +export interface RemoteProxyStatus { + /** Whether the remote proxy is reachable */ + reachable: boolean; + /** Latency in milliseconds (only set if reachable) */ + latencyMs?: number; + /** Error message (only set if not reachable) */ + error?: string; + /** Error code for programmatic handling */ + errorCode?: RemoteProxyErrorCode; +} + +/** Configuration for remote proxy client */ +export interface RemoteProxyClientConfig { + /** Remote proxy host (IP or hostname) */ + host: string; + /** Remote proxy port */ + port: number; + /** Protocol to use (http or https) */ + protocol: 'http' | 'https'; + /** Optional auth token for Authorization header */ + authToken?: string; + /** Request timeout in ms (default: 2000) */ + timeout?: number; + /** Allow self-signed certificates (default: false) */ + allowSelfSigned?: boolean; +} + +/** Default timeout for remote proxy requests (aggressive for CLI UX) */ +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Map error to RemoteProxyErrorCode + */ +function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode { + const message = error.message.toLowerCase(); + const code = (error as NodeJS.ErrnoException).code?.toLowerCase(); + + // Connection refused + if (code === 'econnrefused' || message.includes('connection refused')) { + return 'CONNECTION_REFUSED'; + } + + // Timeout + if ( + code === 'etimedout' || + code === 'timeout' || + message.includes('timeout') || + message.includes('aborted') + ) { + return 'TIMEOUT'; + } + + // Auth failed (401/403) + if (statusCode === 401 || statusCode === 403) { + return 'AUTH_FAILED'; + } + + return 'UNKNOWN'; +} + +/** + * Get human-readable error message from error code + */ +function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): string { + switch (errorCode) { + case 'CONNECTION_REFUSED': + return 'Connection refused - is the proxy running?'; + case 'TIMEOUT': + return 'Connection timed out'; + case 'AUTH_FAILED': + return 'Authentication failed - check auth token'; + default: + return rawError || 'Unknown error'; + } +} + +/** + * Create a custom HTTPS agent for self-signed certificate support + */ +function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined { + if (!allowSelfSigned) return undefined; + + return new https.Agent({ + rejectUnauthorized: false, + }); +} + +/** + * Check health of remote CLIProxyAPI instance + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function checkRemoteProxy( + config: RemoteProxyClientConfig +): Promise { + const { host, port, protocol, authToken, allowSelfSigned = false } = config; + const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + + const url = `${protocol}://${host}:${port}/health`; + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // Build request options + const headers: Record = { + Accept: 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + // For HTTPS with self-signed certs, we need to use native https module + // Bun's fetch doesn't support custom agents + let response: Response; + + if (protocol === 'https' && allowSelfSigned) { + // Warn about security implications + console.error('[!] Allowing self-signed certificate - not recommended for production'); + + // Use native https module for self-signed cert support + response = await new Promise((resolve, reject) => { + const agent = createHttpsAgent(true); + const reqTimeout = setTimeout(() => { + reject(new Error('Request timeout')); + }, timeout); + + const req = https.request( + url, + { + method: 'GET', + headers, + agent, + timeout, + }, + (res) => { + clearTimeout(reqTimeout); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + resolve( + new Response(data, { + status: res.statusCode || 500, + statusText: res.statusMessage, + }) + ); + }); + } + ); + + req.on('error', (err) => { + clearTimeout(reqTimeout); + reject(err); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } else { + // Standard fetch for HTTP or HTTPS without self-signed + response = await fetch(url, { + signal: controller.signal, + headers, + }); + } + + clearTimeout(timeoutId); + + const latencyMs = Date.now() - startTime; + + // Check for auth failure + if (response.status === 401 || response.status === 403) { + return { + reachable: false, + error: getErrorMessage('AUTH_FAILED'), + errorCode: 'AUTH_FAILED', + }; + } + + // 200 OK = healthy + if (response.ok) { + return { + reachable: true, + latencyMs, + }; + } + + // Non-200 but connected + return { + reachable: false, + error: `Unexpected status: ${response.status}`, + errorCode: 'UNKNOWN', + }; + } catch (error) { + const err = error as Error; + const errorCode = mapErrorToCode(err); + + return { + reachable: false, + error: getErrorMessage(errorCode, err.message), + errorCode, + }; + } +} + +/** + * Test connection to remote CLIProxyAPI (alias for dashboard use) + * + * This is an alias for checkRemoteProxy() for semantic clarity in UI contexts. + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function testConnection(config: RemoteProxyClientConfig): Promise { + return checkRemoteProxy(config); +} diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts new file mode 100644 index 00000000..61022c11 --- /dev/null +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for remote-proxy-client module + */ +import { describe, it, expect } from 'bun:test'; +import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client'; + +// We test the module's type exports and error handling logic +// Actual HTTP calls are not mocked in this unit test - use integration tests for that + +describe('remote-proxy-client', () => { + describe('type exports', () => { + it('should export RemoteProxyClientConfig interface', () => { + // Type-level test - ensure the interface shape is correct + const config: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + authToken: 'test-token', + timeout: 2000, + allowSelfSigned: false, + }; + expect(config.host).toBe('localhost'); + expect(config.port).toBe(8317); + expect(config.protocol).toBe('http'); + }); + + it('should export RemoteProxyStatus interface', () => { + // Success case + const successStatus: RemoteProxyStatus = { + reachable: true, + latencyMs: 50, + }; + expect(successStatus.reachable).toBe(true); + expect(successStatus.latencyMs).toBe(50); + + // Error case + const errorStatus: RemoteProxyStatus = { + reachable: false, + error: 'Connection refused', + errorCode: 'CONNECTION_REFUSED', + }; + expect(errorStatus.reachable).toBe(false); + expect(errorStatus.error).toBe('Connection refused'); + expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED'); + }); + }); + + describe('RemoteProxyErrorCode', () => { + it('should define expected error codes', () => { + const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN']; + + // Type-level test - ensure error codes can be used + const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' }; + const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' }; + const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' }; + const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' }; + + expect(validCodes).toContain(status1.errorCode); + expect(validCodes).toContain(status2.errorCode); + expect(validCodes).toContain(status3.errorCode); + expect(validCodes).toContain(status4.errorCode); + }); + }); + + describe('config validation', () => { + it('should require host and port', () => { + const minimalConfig: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + }; + expect(minimalConfig.host).toBeDefined(); + expect(minimalConfig.port).toBeDefined(); + expect(minimalConfig.protocol).toBeDefined(); + }); + + it('should allow optional fields', () => { + const config: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'https', + authToken: 'secret', + timeout: 5000, + allowSelfSigned: true, + }; + expect(config.authToken).toBe('secret'); + expect(config.timeout).toBe(5000); + expect(config.allowSelfSigned).toBe(true); + }); + + it('should accept http and https protocols', () => { + const httpConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + }; + const httpsConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'https', + }; + expect(httpConfig.protocol).toBe('http'); + expect(httpsConfig.protocol).toBe('https'); + }); + }); + + describe('health check URL construction', () => { + it('should construct correct health check URL pattern', () => { + const config: RemoteProxyClientConfig = { + host: '192.168.1.100', + port: 8317, + protocol: 'http', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('http://192.168.1.100:8317/health'); + }); + + it('should construct HTTPS URL when protocol is https', () => { + const config: RemoteProxyClientConfig = { + host: 'secure.example.com', + port: 443, + protocol: 'https', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('https://secure.example.com:443/health'); + }); + }); +});