mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
feat(cliproxy): add remote proxy client for health checks
- checkRemoteProxy() tests remote CLIProxyAPI /health endpoint - testConnection() validates remote proxy connectivity - typed error codes: CONNECTION_REFUSED, TIMEOUT, AUTH_FAILED, UNKNOWN - support for self-signed certificates with allowSelfSigned flag - add type-level tests for interfaces
This commit is contained in:
@@ -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<RemoteProxyStatus> {
|
||||
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<string, string> = {
|
||||
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<Response>((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<RemoteProxyStatus> {
|
||||
return checkRemoteProxy(config);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user