claude-code-best/src/utils/proxy.ts
IronRookieCoder b39887b2d3 feat: 添加代理和 CA 证书功能改进及测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:24:02 +08:00

608 lines
18 KiB
TypeScript

// @aws-sdk/credential-provider-node and @smithy/node-http-handler are imported
// dynamically in getAWSClientProxyConfig() to defer ~929KB of AWS SDK.
// undici is lazy-required inside getProxyAgent/configureGlobalAgents to defer
// ~1.5MB when no HTTPS_PROXY/mTLS env vars are set (the common case).
import axios, { type AxiosInstance } from 'axios'
import type { LookupOptions } from 'dns'
import type { Agent } from 'http'
import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'
import memoize from 'lodash-es/memoize.js'
import { connect } from 'net'
import type * as undici from 'undici'
import { getCACertificates } from './caCerts.js'
import { logForDebugging } from './debug.js'
import { isEnvTruthy } from './envUtils.js'
import {
getMTLSAgent,
getMTLSConfig,
getTLSFetchOptions,
type TLSConfig,
} from './mtls.js'
// Disable fetch keep-alive after a stale-pool ECONNRESET so retries open a
// fresh TCP connection instead of reusing the dead pooled socket. Sticky for
// the process lifetime — once the pool is known-bad, don't trust it again.
// Works under Bun (native fetch respects keepalive:false for pooling).
// Under Node/undici, keepalive is a no-op for pooling, but undici
// naturally evicts dead sockets from the pool on ECONNRESET.
let keepAliveDisabled = false
export function disableKeepAlive(): void {
keepAliveDisabled = true
}
export function _resetKeepAliveForTesting(): void {
keepAliveDisabled = false
}
/**
* Convert dns.LookupOptions.family to a numeric address family value
* Handles: 0 | 4 | 6 | 'IPv4' | 'IPv6' | undefined
*/
export function getAddressFamily(options: LookupOptions): 0 | 4 | 6 {
switch (options.family) {
case 0:
case 4:
case 6:
return options.family
case 'IPv6':
return 6
case 'IPv4':
case undefined:
return 4
default:
throw new Error(`Unsupported address family: ${options.family}`)
}
}
type EnvLike = Record<string, string | undefined>
type ProxyReachabilityStatus = 'checking' | 'reachable' | 'unreachable'
const PROXY_REACHABILITY_TIMEOUT_MS = 500
let proxyReachability:
| {
url: string
status: ProxyReachabilityStatus
}
| undefined
let proxyReachabilityPromise: Promise<boolean> | undefined
/**
* Get the active proxy URL if one is configured
* Prefers lowercase variants over uppercase (https_proxy > HTTPS_PROXY > http_proxy > HTTP_PROXY)
* @param env Environment variables to check (defaults to process.env for production use)
*/
export function getProxyUrl(env: EnvLike = process.env): string | undefined {
const proxyUrl =
env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY
if (!proxyUrl || env !== process.env) {
return proxyUrl
}
if (!isValidProxyUrl(proxyUrl)) {
clearMatchingProxyEnv(proxyUrl)
return undefined
}
ensureProxyReachabilityCheck(proxyUrl)
if (proxyReachability?.url !== proxyUrl) {
return proxyUrl
}
if (proxyReachability.status === 'unreachable') {
return undefined
}
// Most stale proxy env issues come from local desktop proxies. Avoid routing
// startup traffic into a loopback proxy until a quick TCP probe confirms it
// is actually listening.
if (
proxyReachability.status === 'checking' &&
isLoopbackProxyUrl(proxyUrl)
) {
return undefined
}
return proxyUrl
}
/**
* Get the NO_PROXY environment variable value
* Prefers lowercase over uppercase (no_proxy > NO_PROXY)
* @param env Environment variables to check (defaults to process.env for production use)
*/
export function getNoProxy(env: EnvLike = process.env): string | undefined {
return env.no_proxy || env.NO_PROXY
}
/**
* Check if a URL should bypass the proxy based on NO_PROXY environment variable
* Supports:
* - Exact hostname matches (e.g., "localhost")
* - Domain suffix matches with leading dot (e.g., ".example.com")
* - Wildcard "*" to bypass all
* - Port-specific matches (e.g., "example.com:8080")
* - IP addresses (e.g., "127.0.0.1")
* @param urlString URL to check
* @param noProxy NO_PROXY value (defaults to getNoProxy() for production use)
*/
export function shouldBypassProxy(
urlString: string,
noProxy: string | undefined = getNoProxy(),
): boolean {
if (!noProxy) return false
// Handle wildcard
if (noProxy === '*') return true
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
const hostWithPort = `${hostname}:${port}`
// Split by comma or space and trim each entry
const noProxyList = noProxy.split(/[,\s]+/).filter(Boolean)
return noProxyList.some(pattern => {
pattern = pattern.toLowerCase().trim()
// Check for port-specific match
if (pattern.includes(':')) {
return hostWithPort === pattern
}
// Check for domain suffix match (with or without leading dot)
if (pattern.startsWith('.')) {
// Pattern ".example.com" should match "sub.example.com" and "example.com"
// but NOT "notexample.com"
const suffix = pattern
return hostname === pattern.substring(1) || hostname.endsWith(suffix)
}
// Check for exact hostname match or IP address
return hostname === pattern
})
} catch {
// If URL parsing fails, don't bypass proxy
return false
}
}
/**
* Create an HttpsProxyAgent with optional mTLS configuration
* Skips local DNS resolution to let the proxy handle it
*/
function createHttpsProxyAgent(
proxyUrl: string,
extra: HttpsProxyAgentOptions<string> = {},
): HttpsProxyAgent<string> {
const mtlsConfig = getMTLSConfig()
const caCerts = getCACertificates()
const agentOptions: HttpsProxyAgentOptions<string> = {
...(mtlsConfig && {
cert: mtlsConfig.cert,
key: mtlsConfig.key,
passphrase: mtlsConfig.passphrase,
}),
...(caCerts && { ca: caCerts }),
}
if (isEnvTruthy(process.env.CLAUDE_CODE_PROXY_RESOLVES_HOSTS)) {
// Skip local DNS resolution - let the proxy resolve hostnames
// This is needed for environments where DNS is not configured locally
// and instead handled by the proxy (as in sandboxes)
agentOptions.lookup = (hostname, options, callback) => {
callback(null, hostname, getAddressFamily(options))
}
}
return new HttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra })
}
/**
* Axios instance with its own proxy agent. Same NO_PROXY/mTLS/CA
* resolution as the global interceptor, but agent options stay
* scoped to this instance.
*/
export function createAxiosInstance(
extra: HttpsProxyAgentOptions<string> = {},
): AxiosInstance {
const proxyUrl = getProxyUrl()
const mtlsAgent = getMTLSAgent()
const instance = axios.create({ proxy: false })
if (!proxyUrl) {
if (mtlsAgent) instance.defaults.httpsAgent = mtlsAgent
return instance
}
const proxyAgent = createHttpsProxyAgent(proxyUrl, extra)
instance.interceptors.request.use(config => {
if (config.url && shouldBypassProxy(config.url)) {
config.httpsAgent = mtlsAgent
config.httpAgent = mtlsAgent
} else {
config.httpsAgent = proxyAgent
config.httpAgent = proxyAgent
}
return config
})
return instance
}
/**
* Get or create a memoized proxy agent for the given URI
* Now respects NO_PROXY environment variable
*/
export const getProxyAgent = memoize((uri: string): undici.Dispatcher => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const undiciMod = require('undici') as typeof undici
const mtlsConfig = getMTLSConfig()
const caCerts = getCACertificates()
// Use EnvHttpProxyAgent to respect NO_PROXY
// This agent automatically checks NO_PROXY for each request
const proxyOptions: undici.EnvHttpProxyAgent.Options & {
requestTls?: {
cert?: string | Buffer
key?: string | Buffer
passphrase?: string
ca?: string | string[] | Buffer
}
} = {
// Override both HTTP and HTTPS proxy with the provided URI
httpProxy: uri,
httpsProxy: uri,
noProxy: process.env.NO_PROXY || process.env.no_proxy,
}
// Set both connect and requestTls so TLS options apply to both paths:
// - requestTls: used by ProxyAgent for the TLS connection through CONNECT tunnels
// - connect: used by Agent for direct (no-proxy) connections
if (mtlsConfig || caCerts) {
const tlsOpts = {
...(mtlsConfig && {
cert: mtlsConfig.cert,
key: mtlsConfig.key,
passphrase: mtlsConfig.passphrase,
}),
...(caCerts && { ca: caCerts }),
}
proxyOptions.connect = tlsOpts
proxyOptions.requestTls = tlsOpts
}
return new undiciMod.EnvHttpProxyAgent(proxyOptions)
})
/**
* Get an HTTP agent configured for WebSocket proxy support
* Returns undefined if no proxy is configured or URL should bypass proxy
*/
export function getWebSocketProxyAgent(url: string): Agent | undefined {
const proxyUrl = getProxyUrl()
if (!proxyUrl) {
return undefined
}
// Check if URL should bypass proxy
if (shouldBypassProxy(url)) {
return undefined
}
return createHttpsProxyAgent(proxyUrl)
}
/**
* Get the proxy URL for WebSocket connections under Bun.
* Bun's native WebSocket supports a `proxy` string option instead of Node's `agent`.
* Returns undefined if no proxy is configured or URL should bypass proxy.
*/
export function getWebSocketProxyUrl(url: string): string | undefined {
const proxyUrl = getProxyUrl()
if (!proxyUrl) {
return undefined
}
if (shouldBypassProxy(url)) {
return undefined
}
return proxyUrl
}
/**
* Get fetch options for the Anthropic SDK with proxy and mTLS configuration
* Returns fetch options with appropriate dispatcher for proxy and/or mTLS
*
* @param opts.forAnthropicAPI - Enables ANTHROPIC_UNIX_SOCKET tunneling. This
* env var is set by `claude ssh` on the remote CLI to route API calls through
* an ssh -R forwarded unix socket to a local auth proxy. It MUST NOT leak
* into non-Anthropic-API fetch paths (MCP HTTP/SSE transports, etc.) or those
* requests get misrouted to api.anthropic.com. Only the Anthropic SDK client
* should pass `true` here.
*/
export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): {
tls?: TLSConfig
dispatcher?: undici.Dispatcher
proxy?: string
unix?: string
keepalive?: false
} {
const base = keepAliveDisabled ? ({ keepalive: false } as const) : {}
// ANTHROPIC_UNIX_SOCKET tunnels through the `claude ssh` auth proxy, which
// hardcodes the upstream to the Anthropic API. Scope to the Anthropic API
// client so MCP/SSE/other callers don't get their requests misrouted.
if (opts?.forAnthropicAPI) {
const unixSocket = process.env.ANTHROPIC_UNIX_SOCKET
if (unixSocket && typeof Bun !== 'undefined') {
return { ...base, unix: unixSocket }
}
}
const proxyUrl = getProxyUrl()
// If we have a proxy, use the proxy agent (which includes mTLS config)
if (proxyUrl) {
if (typeof Bun !== 'undefined') {
return { ...base, proxy: proxyUrl, ...getTLSFetchOptions() }
}
return { ...base, dispatcher: getProxyAgent(proxyUrl) }
}
// Otherwise, use TLS options directly if available
return { ...base, ...getTLSFetchOptions() }
}
/**
* Configure global HTTP agents for both axios and undici
* This ensures all HTTP requests use the proxy and/or mTLS if configured
*/
let proxyInterceptorId: number | undefined
export function configureGlobalAgents(): void {
const proxyUrl = getProxyUrl()
const mtlsAgent = getMTLSAgent()
const isBunRuntime = typeof Bun !== 'undefined'
// Eject previous interceptor to avoid stacking on repeated calls
if (proxyInterceptorId !== undefined) {
axios.interceptors.request.eject(proxyInterceptorId)
proxyInterceptorId = undefined
}
// Reset proxy-related defaults so reconfiguration is clean
axios.defaults.proxy = undefined
axios.defaults.httpAgent = undefined
axios.defaults.httpsAgent = undefined
if (proxyUrl) {
// workaround for https://github.com/axios/axios/issues/4531
axios.defaults.proxy = false
// Create proxy agent with mTLS options if available
const proxyAgent = createHttpsProxyAgent(proxyUrl)
// Add axios request interceptor to handle NO_PROXY
proxyInterceptorId = axios.interceptors.request.use(config => {
// Check if URL should bypass proxy based on NO_PROXY
if (config.url && shouldBypassProxy(config.url)) {
// Bypass proxy - use mTLS agent if configured, otherwise undefined
if (mtlsAgent) {
config.httpsAgent = mtlsAgent
config.httpAgent = mtlsAgent
} else {
// Remove any proxy agents to use direct connection
delete config.httpsAgent
delete config.httpAgent
}
} else {
// Use proxy agent
config.httpsAgent = proxyAgent
config.httpAgent = proxyAgent
}
return config
})
// Bun fetch uses the per-request `proxy` option from getProxyFetchOptions().
// Avoid requiring undici here: compiled Bun binaries do not need it, and
// proxy env vars should not be the thing that makes startup depend on a
// dynamic Node-only require.
if (!isBunRuntime) {
// Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
getProxyAgent(proxyUrl),
)
}
} else if (mtlsAgent) {
// No proxy but mTLS is configured
axios.defaults.httpsAgent = mtlsAgent
// Set undici global dispatcher with mTLS
const mtlsOptions = getTLSFetchOptions()
if (!isBunRuntime && mtlsOptions.dispatcher) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
mtlsOptions.dispatcher,
)
}
}
}
/**
* Get AWS SDK client configuration with proxy support
* Returns configuration object that can be spread into AWS service client constructors
*/
export async function getAWSClientProxyConfig(): Promise<object> {
const proxyUrl = getProxyUrl()
if (!proxyUrl) {
return {}
}
const [{ NodeHttpHandler }, { defaultProvider }] = await Promise.all([
import('@smithy/node-http-handler'),
import('@aws-sdk/credential-provider-node'),
])
const agent = createHttpsProxyAgent(proxyUrl)
const requestHandler = new NodeHttpHandler({
httpAgent: agent,
httpsAgent: agent,
})
return {
requestHandler,
credentials: defaultProvider({
clientConfig: { requestHandler },
}),
}
}
/**
* Clear proxy agent cache.
*/
export function clearProxyCache(): void {
getProxyAgent.cache.clear?.()
logForDebugging('Cleared proxy agent cache')
}
function ensureProxyReachabilityCheck(proxyUrl: string): void {
if (
proxyReachability?.url === proxyUrl &&
proxyReachability.status !== 'unreachable'
) {
return
}
proxyReachability = {
url: proxyUrl,
status: 'checking',
}
const promise = probeProxyReachability(proxyUrl).catch(error => {
logForDebugging(`Proxy: reachability probe failed: ${error}`, {
level: 'warn',
})
return false
})
proxyReachabilityPromise = promise
void promise.then(reachable => {
if (proxyReachability?.url !== proxyUrl) {
return
}
if (reachable) {
proxyReachability = { url: proxyUrl, status: 'reachable' }
configureGlobalAgents()
return
}
proxyReachability = { url: proxyUrl, status: 'unreachable' }
clearMatchingProxyEnv(proxyUrl)
clearProxyCache()
configureGlobalAgents()
})
}
function probeProxyReachability(proxyUrl: string): Promise<boolean> {
let url: URL
try {
url = new URL(proxyUrl)
} catch {
return Promise.resolve(false)
}
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
const host = getProxyHost(url)
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.resolve(false)
}
return new Promise(resolve => {
const socket = connect({ host, port })
let done = false
const finish = (reachable: boolean) => {
if (done) return
done = true
socket.destroy()
resolve(reachable)
}
socket.setTimeout(PROXY_REACHABILITY_TIMEOUT_MS)
socket.once('connect', () => finish(true))
socket.once('timeout', () => finish(false))
socket.once('error', () => finish(false))
})
}
function isLoopbackProxyUrl(proxyUrl: string): boolean {
try {
const hostname = new URL(proxyUrl).hostname.toLowerCase()
return (
hostname === 'localhost' ||
hostname === '::1' ||
hostname === '[::1]' ||
hostname.startsWith('127.')
)
} catch {
return false
}
}
function getProxyHost(url: URL): string {
return url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname
}
function isValidProxyUrl(proxyUrl: string): boolean {
try {
const url = new URL(proxyUrl)
return (
!!url.hostname &&
(url.protocol === 'http:' || url.protocol === 'https:')
)
} catch {
return false
}
}
function clearMatchingProxyEnv(proxyUrl: string): void {
for (const key of [
'https_proxy',
'HTTPS_PROXY',
'http_proxy',
'HTTP_PROXY',
]) {
if (process.env[key] === proxyUrl) {
delete process.env[key]
}
}
logForDebugging(`Proxy: disabled unreachable proxy ${proxyUrl}`, {
level: 'warn',
})
}
export function _resetProxyReachabilityForTesting(): void {
proxyReachability = undefined
proxyReachabilityPromise = undefined
}
export async function _waitForProxyReachabilityForTesting(): Promise<
boolean | undefined
> {
return proxyReachabilityPromise
}