mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
Merge pull request #454 from kaitranntt/kai/fix/266-cliproxy-proxy-support
fix(cliproxy): respect http_proxy env vars for binary downloads
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,13 +2,107 @@
|
||||
* 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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default configuration for downloader */
|
||||
export interface DownloaderConfig {
|
||||
/** Maximum retry attempts */
|
||||
@@ -154,12 +248,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 +382,7 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<s
|
||||
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);
|
||||
@@ -352,7 +446,7 @@ function fetchJsonOnce(
|
||||
'User-Agent': 'CCS-CLIProxyPlus-Updater/1.0',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
agent: false, // Disable connection pooling for clean exit
|
||||
agent: getProxyAgent(url),
|
||||
};
|
||||
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
@@ -445,3 +539,11 @@ export async function fetchJson(
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Export internal functions for testing
|
||||
export const __testExports = {
|
||||
getProxyUrl,
|
||||
shouldBypassProxy,
|
||||
getHostname,
|
||||
getProxyAgent,
|
||||
};
|
||||
|
||||
@@ -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<string, string | undefined> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user