From 9c527b7d1501deb9aefff6ab95debb18adee87f0 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 16:46:14 -0500 Subject: [PATCH 1/3] fix(cliproxy): respect http_proxy env vars for binary downloads CLIProxyAPI installation and updates now respect http_proxy, https_proxy, and all_proxy environment variables when making network requests. Uses https-proxy-agent and http-proxy-agent packages to route HTTP/HTTPS requests through the configured proxy server. Closes #266 --- bun.lock | 3 +- package.json | 2 ++ src/cliproxy/binary/downloader.ts | 54 ++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index b80a4d5f..e9396b8b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@kaitranntt/ccs", @@ -15,6 +14,8 @@ "express-session": "^1.18.2", "get-port": "^5.1.1", "gradient-string": "^2.0.2", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "js-yaml": "^4.1.1", "listr2": "^3.14.0", "open": "^8.4.2", diff --git a/package.json b/package.json index 2d83d6fa..8c43e421 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,8 @@ "express-session": "^1.18.2", "get-port": "^5.1.1", "gradient-string": "^2.0.2", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "js-yaml": "^4.1.1", "listr2": "^3.14.0", "open": "^8.4.2", diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index b06a4e08..69b400b0 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -2,13 +2,59 @@ * Binary Downloader * Handles downloading files with retry logic, progress tracking, and redirect following. * Robust handling for transient network errors (socket hang up, ECONNRESET, etc.) + * Respects http_proxy, https_proxy, and all_proxy environment variables. */ import * as fs from 'fs'; import * as https from 'https'; import * as http from 'http'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; import { DownloadResult, ProgressCallback } from '../types'; +/** + * Get proxy URL from environment variables. + * Checks: https_proxy, HTTPS_PROXY, http_proxy, HTTP_PROXY, all_proxy, ALL_PROXY + * @param isHttps Whether the target URL is HTTPS + * @returns Proxy URL or undefined if no proxy configured + */ +function getProxyUrl(isHttps: boolean): string | undefined { + if (isHttps) { + return ( + process.env.https_proxy || + process.env.HTTPS_PROXY || + process.env.all_proxy || + process.env.ALL_PROXY + ); + } + return ( + process.env.http_proxy || + process.env.HTTP_PROXY || + process.env.all_proxy || + process.env.ALL_PROXY + ); +} + +/** + * Create appropriate proxy agent based on URL protocol. + * @param url Target URL to determine protocol + * @returns Proxy agent or false (no agent/pooling disabled) + */ +function getProxyAgent(url: string): http.Agent | https.Agent | false { + const isHttps = url.startsWith('https'); + const proxyUrl = getProxyUrl(isHttps); + + if (!proxyUrl) { + return false; // No proxy configured, disable connection pooling for clean exit + } + + // Use appropriate agent based on target URL protocol + if (isHttps) { + return new HttpsProxyAgent(proxyUrl); + } + return new HttpProxyAgent(proxyUrl); +} + /** Default configuration for downloader */ export interface DownloaderConfig { /** Maximum retry attempts */ @@ -154,12 +200,12 @@ export function downloadFile( const protocol = url.startsWith('https') ? https : http; - // Use agent: false to prevent connection pooling (allows process to exit) + // Use proxy agent if configured, otherwise disable connection pooling for clean exit const options = { headers: { 'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0', }, - agent: false, // Disable connection pooling for clean exit + agent: getProxyAgent(url), }; const req = protocol.get(url, options, handleResponse); @@ -288,7 +334,7 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise { From bcde5f4878731d45f4867d52300dcca623e0915f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 16:59:18 -0500 Subject: [PATCH 2/3] fix(cliproxy): add NO_PROXY support and error handling for proxy URLs - Add shouldBypassProxy() to respect NO_PROXY/no_proxy env var - Supports exact match, wildcard (*), and domain suffix patterns - Add try-catch for malformed proxy URLs with graceful fallback - Extract getHostname() helper for URL parsing --- src/cliproxy/binary/downloader.ts | 56 ++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index 69b400b0..500b673f 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -35,8 +35,44 @@ function getProxyUrl(isHttps: boolean): string | undefined { ); } +/** + * Check if a hostname should bypass the proxy based on NO_PROXY/no_proxy env var. + * Supports: exact match, wildcard (*), and domain suffix (.example.com) + * @param hostname The hostname to check + * @returns true if the hostname should bypass the proxy + */ +function shouldBypassProxy(hostname: string): boolean { + const noProxy = process.env.no_proxy || process.env.NO_PROXY; + if (!noProxy) return false; + + const noProxyList = noProxy.split(',').map((s) => s.trim().toLowerCase()); + const host = hostname.toLowerCase(); + + return noProxyList.some((pattern) => { + if (pattern === '*') return true; + if (pattern.startsWith('.')) { + return host.endsWith(pattern) || host === pattern.slice(1); + } + return host === pattern || host.endsWith('.' + pattern); + }); +} + +/** + * Extract hostname from URL. + * @param url The URL to parse + * @returns Hostname or empty string if invalid + */ +function getHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + /** * Create appropriate proxy agent based on URL protocol. + * Respects NO_PROXY/no_proxy for bypassing specific hosts. * @param url Target URL to determine protocol * @returns Proxy agent or false (no agent/pooling disabled) */ @@ -48,11 +84,23 @@ function getProxyAgent(url: string): http.Agent | https.Agent | false { return false; // No proxy configured, disable connection pooling for clean exit } - // Use appropriate agent based on target URL protocol - if (isHttps) { - return new HttpsProxyAgent(proxyUrl); + // Check if this host should bypass the proxy + const hostname = getHostname(url); + if (hostname && shouldBypassProxy(hostname)) { + return false; // Bypass proxy for this host + } + + // Create proxy agent with error handling for malformed URLs + try { + if (isHttps) { + return new HttpsProxyAgent(proxyUrl); + } + return new HttpProxyAgent(proxyUrl); + } catch { + // Invalid proxy URL, fall back to direct connection + console.error(`[cliproxy] Invalid proxy URL: ${proxyUrl}`); + return false; } - return new HttpProxyAgent(proxyUrl); } /** Default configuration for downloader */ From 713ee936065d6b1f7f61a9aa07282c5f82d81774 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 17:05:24 -0500 Subject: [PATCH 3/3] test(cliproxy): add comprehensive proxy support unit tests Add 34 unit tests covering: - getProxyUrl: env var precedence (lowercase > uppercase > all_proxy) - shouldBypassProxy: wildcard, exact match, suffix patterns, case-insensitivity - getHostname: URL parsing with error handling - getProxyAgent: proxy creation, NO_PROXY bypass, error handling Export internal functions via __testExports for testability. --- src/cliproxy/binary/downloader.ts | 8 + .../cliproxy/binary-downloader-proxy.test.ts | 260 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 tests/unit/cliproxy/binary-downloader-proxy.test.ts diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index 500b673f..e34c5490 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -539,3 +539,11 @@ export async function fetchJson( function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +// Export internal functions for testing +export const __testExports = { + getProxyUrl, + shouldBypassProxy, + getHostname, + getProxyAgent, +}; diff --git a/tests/unit/cliproxy/binary-downloader-proxy.test.ts b/tests/unit/cliproxy/binary-downloader-proxy.test.ts new file mode 100644 index 00000000..a92926f5 --- /dev/null +++ b/tests/unit/cliproxy/binary-downloader-proxy.test.ts @@ -0,0 +1,260 @@ +/** + * Binary Downloader Proxy Support Tests + * + * Tests for proxy environment variable detection and NO_PROXY bypass logic. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { __testExports } from '../../../src/cliproxy/binary/downloader'; + +const { getProxyUrl, shouldBypassProxy, getHostname, getProxyAgent } = __testExports; + +describe('Binary Downloader Proxy Support', () => { + // Store original env vars to restore after tests + const originalEnv: Record = {}; + const proxyEnvVars = [ + 'http_proxy', + 'HTTP_PROXY', + 'https_proxy', + 'HTTPS_PROXY', + 'all_proxy', + 'ALL_PROXY', + 'no_proxy', + 'NO_PROXY', + ]; + + beforeEach(() => { + // Save original values + proxyEnvVars.forEach((key) => { + originalEnv[key] = process.env[key]; + delete process.env[key]; + }); + }); + + afterEach(() => { + // Restore original values + proxyEnvVars.forEach((key) => { + if (originalEnv[key] !== undefined) { + process.env[key] = originalEnv[key]; + } else { + delete process.env[key]; + } + }); + }); + + describe('getProxyUrl', () => { + describe('HTTPS requests', () => { + it('should return undefined when no proxy env vars are set', () => { + expect(getProxyUrl(true)).toBeUndefined(); + }); + + it('should prefer https_proxy (lowercase)', () => { + process.env.https_proxy = 'http://proxy1:8080'; + process.env.HTTPS_PROXY = 'http://proxy2:8080'; + expect(getProxyUrl(true)).toBe('http://proxy1:8080'); + }); + + it('should fall back to HTTPS_PROXY (uppercase)', () => { + process.env.HTTPS_PROXY = 'http://proxy:8080'; + expect(getProxyUrl(true)).toBe('http://proxy:8080'); + }); + + it('should fall back to all_proxy', () => { + process.env.all_proxy = 'http://allproxy:8080'; + expect(getProxyUrl(true)).toBe('http://allproxy:8080'); + }); + + it('should fall back to ALL_PROXY', () => { + process.env.ALL_PROXY = 'http://allproxy:8080'; + expect(getProxyUrl(true)).toBe('http://allproxy:8080'); + }); + }); + + describe('HTTP requests', () => { + it('should return undefined when no proxy env vars are set', () => { + expect(getProxyUrl(false)).toBeUndefined(); + }); + + it('should prefer http_proxy (lowercase)', () => { + process.env.http_proxy = 'http://proxy1:8080'; + process.env.HTTP_PROXY = 'http://proxy2:8080'; + expect(getProxyUrl(false)).toBe('http://proxy1:8080'); + }); + + it('should fall back to HTTP_PROXY (uppercase)', () => { + process.env.HTTP_PROXY = 'http://proxy:8080'; + expect(getProxyUrl(false)).toBe('http://proxy:8080'); + }); + + it('should fall back to all_proxy for HTTP', () => { + process.env.all_proxy = 'http://allproxy:8080'; + expect(getProxyUrl(false)).toBe('http://allproxy:8080'); + }); + + it('should not use https_proxy for HTTP requests', () => { + process.env.https_proxy = 'http://httpsproxy:8080'; + expect(getProxyUrl(false)).toBeUndefined(); + }); + }); + }); + + describe('shouldBypassProxy', () => { + describe('when NO_PROXY is not set', () => { + it('should return false', () => { + expect(shouldBypassProxy('example.com')).toBe(false); + }); + }); + + describe('wildcard pattern', () => { + it('should bypass all hosts with *', () => { + process.env.NO_PROXY = '*'; + expect(shouldBypassProxy('example.com')).toBe(true); + expect(shouldBypassProxy('any.host.here')).toBe(true); + }); + }); + + describe('exact match', () => { + it('should match exact hostname', () => { + process.env.NO_PROXY = 'example.com'; + expect(shouldBypassProxy('example.com')).toBe(true); + expect(shouldBypassProxy('other.com')).toBe(false); + }); + + it('should be case-insensitive', () => { + process.env.NO_PROXY = 'Example.COM'; + expect(shouldBypassProxy('example.com')).toBe(true); + expect(shouldBypassProxy('EXAMPLE.COM')).toBe(true); + }); + + it('should not match subdomains for exact pattern', () => { + process.env.NO_PROXY = 'example.com'; + expect(shouldBypassProxy('sub.example.com')).toBe(true); // matches as suffix + expect(shouldBypassProxy('notexample.com')).toBe(false); + }); + }); + + describe('domain suffix pattern', () => { + it('should match subdomains with leading dot', () => { + process.env.NO_PROXY = '.example.com'; + expect(shouldBypassProxy('sub.example.com')).toBe(true); + expect(shouldBypassProxy('deep.sub.example.com')).toBe(true); + expect(shouldBypassProxy('example.com')).toBe(true); // exact match without dot + }); + + it('should not match different domains', () => { + process.env.NO_PROXY = '.example.com'; + expect(shouldBypassProxy('notexample.com')).toBe(false); + expect(shouldBypassProxy('example.org')).toBe(false); + }); + }); + + describe('multiple patterns', () => { + it('should check comma-separated list', () => { + process.env.NO_PROXY = 'localhost,127.0.0.1,.internal.corp'; + expect(shouldBypassProxy('localhost')).toBe(true); + expect(shouldBypassProxy('127.0.0.1')).toBe(true); + expect(shouldBypassProxy('api.internal.corp')).toBe(true); + expect(shouldBypassProxy('external.com')).toBe(false); + }); + + it('should handle whitespace in list', () => { + process.env.NO_PROXY = ' localhost , example.com , .corp '; + expect(shouldBypassProxy('localhost')).toBe(true); + expect(shouldBypassProxy('example.com')).toBe(true); + expect(shouldBypassProxy('host.corp')).toBe(true); + }); + }); + + describe('lowercase no_proxy', () => { + it('should respect no_proxy (lowercase)', () => { + process.env.no_proxy = 'example.com'; + expect(shouldBypassProxy('example.com')).toBe(true); + }); + + it('should prefer no_proxy over NO_PROXY', () => { + process.env.no_proxy = 'lower.com'; + process.env.NO_PROXY = 'upper.com'; + expect(shouldBypassProxy('lower.com')).toBe(true); + expect(shouldBypassProxy('upper.com')).toBe(false); + }); + }); + }); + + describe('getHostname', () => { + it('should extract hostname from HTTPS URL', () => { + expect(getHostname('https://example.com/path')).toBe('example.com'); + }); + + it('should extract hostname from HTTP URL', () => { + expect(getHostname('http://api.github.com:443/repos')).toBe('api.github.com'); + }); + + it('should return empty string for invalid URL', () => { + expect(getHostname('not-a-url')).toBe(''); + expect(getHostname('')).toBe(''); + }); + + it('should handle localhost', () => { + expect(getHostname('http://localhost:8080')).toBe('localhost'); + }); + + it('should handle IP addresses', () => { + expect(getHostname('http://192.168.1.1:3000')).toBe('192.168.1.1'); + }); + }); + + describe('getProxyAgent', () => { + describe('when no proxy is configured', () => { + it('should return false for HTTPS URLs', () => { + expect(getProxyAgent('https://example.com')).toBe(false); + }); + + it('should return false for HTTP URLs', () => { + expect(getProxyAgent('http://example.com')).toBe(false); + }); + }); + + describe('when proxy is configured', () => { + it('should return HttpsProxyAgent for HTTPS URLs', () => { + process.env.https_proxy = 'http://proxy:8080'; + const agent = getProxyAgent('https://github.com/releases'); + expect(agent).not.toBe(false); + expect(agent).toBeDefined(); + }); + + it('should return HttpProxyAgent for HTTP URLs', () => { + process.env.http_proxy = 'http://proxy:8080'; + const agent = getProxyAgent('http://example.com'); + expect(agent).not.toBe(false); + expect(agent).toBeDefined(); + }); + }); + + describe('NO_PROXY bypass', () => { + it('should bypass proxy for NO_PROXY hosts', () => { + process.env.https_proxy = 'http://proxy:8080'; + process.env.NO_PROXY = 'github.com'; + expect(getProxyAgent('https://github.com/releases')).toBe(false); + }); + + it('should use proxy for non-bypassed hosts', () => { + process.env.https_proxy = 'http://proxy:8080'; + process.env.NO_PROXY = 'internal.corp'; + const agent = getProxyAgent('https://github.com/releases'); + expect(agent).not.toBe(false); + }); + }); + + describe('error handling', () => { + it('should return false for invalid proxy URL', () => { + process.env.https_proxy = 'not-a-valid-url'; + expect(getProxyAgent('https://example.com')).toBe(false); + }); + + it('should return false for invalid target URL', () => { + process.env.https_proxy = 'http://proxy:8080'; + expect(getProxyAgent('not-a-url')).toBe(false); + }); + }); + }); +});