diff --git a/README.md b/README.md index 366d4df2..da771632 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,26 @@ See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-pro
+## Standard Fetch Proxy + +CCS also respects standard proxy environment variables for fetch-based quota, dashboard, +and provider management requests: + +```bash +export HTTPS_PROXY=http://proxy.example.com:8080 +export HTTP_PROXY=http://proxy.example.com:8080 +export ALL_PROXY=http://proxy.example.com:8080 +export NO_PROXY=localhost,127.0.0.1,.internal.corp +``` + +Notes: +- CCS automatically bypasses loopback addresses (`localhost`, `127.0.0.1`, `::1`) for its own local services. +- If `HTTPS_PROXY` is unset, CCS falls back to `HTTP_PROXY` for HTTPS fetches. +- `ALL_PROXY` is used when protocol-specific proxy variables are not configured. +- Proxy URLs must use `http://` or `https://`. + +
+ ## Documentation | Topic | Link | diff --git a/bun.lock b/bun.lock index 3ae74a56..6ba390ab 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ "open": "^8.4.2", "ora": "^5.4.1", "proper-lockfile": "^4.1.2", - "undici": "^6.21.1", + "undici": "^5.29.0", "ws": "^8.16.0", }, "devDependencies": { @@ -1282,7 +1282,7 @@ "uid-safe": ["uid-safe@2.1.5", "", { "dependencies": { "random-bytes": "~1.0.0" } }, "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA=="], - "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1350,8 +1350,6 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2062,8 +2060,6 @@ "semantic-release/@semantic-release/npm/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], - "semantic-release/@semantic-release/npm/@actions/core/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/package.json b/package.json index 2d6d5f7d..2272a925 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "open": "^8.4.2", "ora": "^5.4.1", "proper-lockfile": "^4.1.2", - "undici": "^6.21.1", + "undici": "^5.29.0", "ws": "^8.16.0" }, "devDependencies": { diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index c7e1ea71..9bfc3bad 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -11,51 +11,7 @@ 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); - }); -} +import { getProxyUrl, shouldBypassProxy } from '../../utils/proxy-env'; /** * Extract hostname from URL. diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts index bb2917cb..0622114f 100644 --- a/src/utils/fetch-proxy-setup.ts +++ b/src/utils/fetch-proxy-setup.ts @@ -1,11 +1,157 @@ -import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; +/** + * Global Fetch Proxy Setup + * + * Configures undici's global dispatcher to respect standard proxy environment + * variables without routing CCS loopback traffic back through the proxy. + */ -const proxyUrl = - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy; +import { Agent, Dispatcher, ProxyAgent, setGlobalDispatcher } from 'undici'; +import { getProxyResolution, shouldBypassProxy } from './proxy-env'; -if (proxyUrl) { - setGlobalDispatcher(new EnvHttpProxyAgent()); +type GlobalFetchProxyConfig = { + httpProxyUrl?: string; + httpsProxyUrl?: string; + error?: string; +}; + +class RoutingProxyDispatcher extends Dispatcher { + private readonly directDispatcher = new Agent(); + private readonly httpProxyDispatcher: ProxyAgent | null; + private readonly httpsProxyDispatcher: ProxyAgent | null; + + constructor(httpProxyUrl: string | undefined, httpsProxyUrl: string | undefined) { + super(); + this.httpProxyDispatcher = httpProxyUrl ? new ProxyAgent(httpProxyUrl) : null; + this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null; + } + + dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean { + return this.resolveDispatcher(options.origin).dispatch(options, handler); + } + + close(): Promise; + close(callback: () => void): void; + close(callback?: () => void): Promise | void { + const promise = Promise.all(this.getDispatchers().map((dispatcher) => dispatcher.close())).then( + () => undefined + ); + + if (callback) { + promise.then( + () => callback(), + () => callback() + ); + return; + } + + return promise; + } + + destroy(): Promise; + destroy(err: Error | null): Promise; + destroy(callback: () => void): void; + destroy(err: Error | null, callback: () => void): void; + destroy( + errOrCallback?: Error | null | (() => void), + callback?: () => void + ): Promise | void { + const error = typeof errOrCallback === 'function' ? null : errOrCallback; + const done = typeof errOrCallback === 'function' ? errOrCallback : callback; + const promise = Promise.all( + this.getDispatchers().map((dispatcher) => dispatcher.destroy(error ?? null)) + ).then(() => undefined); + + if (done) { + promise.then( + () => done(), + () => done() + ); + return; + } + + return promise; + } + + private resolveDispatcher(origin: string | URL | undefined): Dispatcher { + if (!origin) { + return this.directDispatcher; + } + + let url: URL; + try { + url = origin instanceof URL ? origin : new URL(origin); + } catch { + return this.directDispatcher; + } + + if (shouldBypassProxy(url.hostname)) { + return this.directDispatcher; + } + + if (url.protocol === 'https:' && this.httpsProxyDispatcher) { + return this.httpsProxyDispatcher; + } + + if (url.protocol === 'http:' && this.httpProxyDispatcher) { + return this.httpProxyDispatcher; + } + + return this.directDispatcher; + } + + private getDispatchers(): Dispatcher[] { + return Array.from( + new Set( + [this.directDispatcher, this.httpProxyDispatcher, this.httpsProxyDispatcher].filter( + (dispatcher): dispatcher is Dispatcher => dispatcher !== null + ) + ) + ); + } +} + +export function createGlobalFetchProxyDispatcher(): Dispatcher | null { + const { httpProxyUrl, httpsProxyUrl } = resolveGlobalFetchProxyConfig(); + + if (!httpProxyUrl && !httpsProxyUrl) { + return null; + } + + return new RoutingProxyDispatcher(httpProxyUrl, httpsProxyUrl); +} + +export function applyGlobalFetchProxy(): { enabled: boolean; error?: string } { + try { + const config = resolveGlobalFetchProxyConfig(); + if (!config.httpProxyUrl && !config.httpsProxyUrl) { + return config.error ? { enabled: false, error: config.error } : { enabled: false }; + } + + const dispatcher = createGlobalFetchProxyDispatcher(); + if (!dispatcher) { + return { enabled: false }; + } + + setGlobalDispatcher(dispatcher); + return { enabled: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown proxy configuration error'; + return { enabled: false, error: message }; + } +} + +const setupResult = applyGlobalFetchProxy(); +if (setupResult.error) { + console.error(`[!] Skipping global fetch proxy setup: ${setupResult.error}`); +} + +function resolveGlobalFetchProxyConfig(): GlobalFetchProxyConfig { + const httpProxy = getProxyResolution(false); + const httpsProxy = getProxyResolution(true); + + return { + httpProxyUrl: httpProxy.url, + httpsProxyUrl: httpsProxy.url, + error: httpProxy.error ?? httpsProxy.error, + }; } diff --git a/src/utils/proxy-env.ts b/src/utils/proxy-env.ts new file mode 100644 index 00000000..f2876e0c --- /dev/null +++ b/src/utils/proxy-env.ts @@ -0,0 +1,150 @@ +type ProxyEnv = Record; +type ProxyResolution = { url?: string; error?: string }; + +const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']); + +function getEnvValue(env: ProxyEnv, keys: string[]): string | undefined { + for (const key of keys) { + const value = env[key]; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + + return undefined; +} + +function normalizeHostname(hostname: string): string { + const trimmed = hostname.trim().toLowerCase().replace(/\.$/, ''); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function isIpLikeHost(hostname: string): boolean { + return /^[\d.:]+$/.test(hostname); +} + +function validateProxyUrl(proxyUrl: string): string { + let parsedUrl: URL; + try { + parsedUrl = new URL(proxyUrl); + } catch { + throw new Error('Invalid URL'); + } + + if (!SUPPORTED_PROXY_PROTOCOLS.has(parsedUrl.protocol)) { + throw new Error(`Unsupported proxy protocol: ${parsedUrl.protocol}`); + } + + return proxyUrl; +} + +function getProxyKeys(isHttps: boolean): string[] { + if (isHttps) { + return ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY']; + } + + return ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY']; +} + +export function getProxyResolution(isHttps: boolean, env: ProxyEnv = process.env): ProxyResolution { + let firstError: string | undefined; + + for (const key of getProxyKeys(isHttps)) { + const proxyUrl = getEnvValue(env, [key]); + if (!proxyUrl) { + continue; + } + + try { + return { url: validateProxyUrl(proxyUrl) }; + } catch (error) { + if (!firstError) { + firstError = error instanceof Error ? error.message : 'Unknown proxy configuration error'; + } + } + } + + return { error: firstError }; +} + +export function getProxyUrl(isHttps: boolean, env: ProxyEnv = process.env): string | undefined { + const { url } = getProxyResolution(isHttps, env); + return url; +} + +export function isLoopbackHost(hostname: string): boolean { + const normalizedHost = normalizeHostname(hostname); + if (!normalizedHost) { + return false; + } + + if ( + normalizedHost === 'localhost' || + normalizedHost.endsWith('.localhost') || + normalizedHost === '0.0.0.0' || + normalizedHost === '::1' + ) { + return true; + } + + if (/^127(?:\.\d{1,3}){3}$/.test(normalizedHost)) { + return true; + } + + return normalizedHost.startsWith('::ffff:127.') || normalizedHost.startsWith('::ffff:7f'); +} + +export function getNoProxyValue(env: ProxyEnv = process.env): string | undefined { + return getEnvValue(env, ['no_proxy', 'NO_PROXY']); +} + +export function shouldBypassProxy(hostname: string, env: ProxyEnv = process.env): boolean { + const normalizedHost = normalizeHostname(hostname); + if (!normalizedHost) { + return false; + } + + if (isLoopbackHost(normalizedHost)) { + return true; + } + + const noProxy = getNoProxyValue(env); + if (!noProxy) { + return false; + } + + const patterns = noProxy + .split(/[,\s]+/) + .map((pattern) => normalizeHostname(pattern)) + .filter(Boolean); + + for (const pattern of patterns) { + if (pattern === '*') { + return true; + } + + const canonicalPattern = pattern.startsWith('.') + ? pattern.slice(1) + : pattern.replace(/^\*/, ''); + + if (!canonicalPattern) { + continue; + } + + if (normalizedHost === canonicalPattern) { + return true; + } + + if (!isIpLikeHost(canonicalPattern) && normalizedHost.endsWith(`.${canonicalPattern}`)) { + return true; + } + } + + return false; +} diff --git a/tests/unit/cliproxy/binary-downloader-proxy.test.ts b/tests/unit/cliproxy/binary-downloader-proxy.test.ts index a92926f5..ad1cee11 100644 --- a/tests/unit/cliproxy/binary-downloader-proxy.test.ts +++ b/tests/unit/cliproxy/binary-downloader-proxy.test.ts @@ -68,6 +68,12 @@ describe('Binary Downloader Proxy Support', () => { process.env.ALL_PROXY = 'http://allproxy:8080'; expect(getProxyUrl(true)).toBe('http://allproxy:8080'); }); + + it('should skip invalid HTTP_PROXY and fall back to ALL_PROXY', () => { + process.env.HTTP_PROXY = 'not-a-valid-url'; + process.env.ALL_PROXY = 'http://allproxy:8080'; + expect(getProxyUrl(true)).toBe('http://allproxy:8080'); + }); }); describe('HTTP requests', () => { @@ -91,6 +97,12 @@ describe('Binary Downloader Proxy Support', () => { expect(getProxyUrl(false)).toBe('http://allproxy:8080'); }); + it('should skip invalid HTTP_PROXY and use ALL_PROXY for HTTP', () => { + process.env.HTTP_PROXY = 'not-a-valid-url'; + 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(); @@ -251,6 +263,11 @@ describe('Binary Downloader Proxy Support', () => { expect(getProxyAgent('https://example.com')).toBe(false); }); + it('should reject unsupported proxy protocols', () => { + process.env.https_proxy = 'socks5://proxy:1080'; + 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); diff --git a/tests/unit/utils/fetch-proxy-setup.test.ts b/tests/unit/utils/fetch-proxy-setup.test.ts new file mode 100644 index 00000000..a460216b --- /dev/null +++ b/tests/unit/utils/fetch-proxy-setup.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as http from 'node:http'; +import { fetch, getGlobalDispatcher, setGlobalDispatcher } from 'undici'; +import { applyGlobalFetchProxy } from '../../../src/utils/fetch-proxy-setup'; + +const PROXY_ENV_KEYS = [ + 'http_proxy', + 'HTTP_PROXY', + 'https_proxy', + 'HTTPS_PROXY', + 'all_proxy', + 'ALL_PROXY', + 'no_proxy', + 'NO_PROXY', +] as const; + +describe('global fetch proxy setup', () => { + const originalEnv = new Map(); + const originalDispatcher = getGlobalDispatcher(); + + beforeEach(() => { + for (const key of PROXY_ENV_KEYS) { + originalEnv.set(key, process.env[key]); + delete process.env[key]; + } + setGlobalDispatcher(originalDispatcher); + }); + + afterEach(() => { + setGlobalDispatcher(originalDispatcher); + for (const key of PROXY_ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('does not fail when proxy configuration is invalid', () => { + process.env.HTTP_PROXY = 'not-a-valid-url'; + + expect(applyGlobalFetchProxy()).toEqual({ + enabled: false, + error: 'Invalid URL', + }); + }); + + it('bypasses loopback fetches even when HTTP_PROXY is set', async () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:9'; + + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind test server'); + } + + try { + expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + + const response = await fetch(`http://127.0.0.1:${address.port}/health`); + expect(response.status).toBe(200); + expect(await response.text()).toBe('ok'); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it('supports ALL_PROXY as a fetch proxy fallback', () => { + process.env.ALL_PROXY = 'http://127.0.0.1:8080'; + + expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + }); + + it('falls back to ALL_PROXY when HTTP_PROXY is invalid', () => { + process.env.HTTP_PROXY = 'not-a-valid-url'; + process.env.ALL_PROXY = 'http://127.0.0.1:8080'; + + expect(applyGlobalFetchProxy()).toEqual({ enabled: true }); + }); +});