feat: 添加代理和 CA 证书功能改进及测试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
IronRookieCoder 2026-05-12 15:35:55 +08:00
parent 79548c324e
commit b39887b2d3
5 changed files with 427 additions and 26 deletions

View File

@ -18,6 +18,7 @@ import {
waitForRemoteManagedSettingsToLoad,
} from '../services/remoteManagedSettings/index.js'
import { preconnectAnthropicApi } from '../utils/apiPreconnect.js'
import { validateExtraCACertsEnv } from '../utils/caCerts.js'
import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js'
import { registerCleanup } from '../utils/cleanupRegistry.js'
import {
@ -91,6 +92,7 @@ export const init = memoize(async (): Promise<void> => {
// before any TLS connections. Bun caches the TLS cert store at boot
// via BoringSSL, so this must happen before the first TLS handshake.
applyExtraCACertsFromConfig()
validateExtraCACertsEnv()
logForDiagnosticsNoPII('info', 'init_safe_env_vars_applied', {
duration_ms: Date.now() - envVarsStart,

View File

@ -0,0 +1,94 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { debugMock } from "../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
const { clearCACertsCache, getCACertificates, validateExtraCACertsEnv } =
await import("../caCerts");
const originalNodeExtraCACerts = process.env.NODE_EXTRA_CA_CERTS;
const originalNodeOptions = process.env.NODE_OPTIONS;
let tempDir: string;
describe("getCACertificates", () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "csc-ca-certs-"));
delete process.env.NODE_EXTRA_CA_CERTS;
delete process.env.NODE_OPTIONS;
clearCACertsCache();
});
afterEach(() => {
restoreEnvVar("NODE_EXTRA_CA_CERTS", originalNodeExtraCACerts);
restoreEnvVar("NODE_OPTIONS", originalNodeOptions);
clearCACertsCache();
rmSync(tempDir, { recursive: true, force: true });
});
test("ignores missing NODE_EXTRA_CA_CERTS path", () => {
process.env.NODE_EXTRA_CA_CERTS = join(tempDir, "missing.pem");
expect(getCACertificates()).toBeUndefined();
expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
test("validates and clears missing NODE_EXTRA_CA_CERTS before TLS setup", () => {
process.env.NODE_EXTRA_CA_CERTS = join(tempDir, "missing.pem");
validateExtraCACertsEnv();
expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
test("ignores directory NODE_EXTRA_CA_CERTS path", () => {
process.env.NODE_EXTRA_CA_CERTS = tempDir;
expect(getCACertificates()).toBeUndefined();
expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
test("appends readable NODE_EXTRA_CA_CERTS file", () => {
const certPath = join(tempDir, "extra.pem");
const cert = "-----BEGIN CERTIFICATE-----\nextra\n-----END CERTIFICATE-----\n";
writeFileSync(certPath, cert);
process.env.NODE_EXTRA_CA_CERTS = certPath;
expect(getCACertificates()).toContain(cert);
expect(process.env.NODE_EXTRA_CA_CERTS).toBe(certPath);
});
test("follows NODE_EXTRA_CA_CERTS symlink to a regular file", () => {
if (process.platform === "win32") {
return;
}
const certPath = join(tempDir, "extra.pem");
const linkPath = join(tempDir, "extra-link.pem");
const cert = "-----BEGIN CERTIFICATE-----\nlinked\n-----END CERTIFICATE-----\n";
writeFileSync(certPath, cert);
symlinkSync(certPath, linkPath);
process.env.NODE_EXTRA_CA_CERTS = linkPath;
expect(getCACertificates()).toContain(cert);
expect(process.env.NODE_EXTRA_CA_CERTS).toBe(linkPath);
});
});
function restoreEnvVar(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}

View File

@ -0,0 +1,98 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { createServer } from "node:net";
import { debugMock } from "../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
const {
_resetProxyReachabilityForTesting,
_waitForProxyReachabilityForTesting,
getProxyUrl,
} = await import("../proxy");
const originalProxyEnv = {
https_proxy: process.env.https_proxy,
HTTPS_PROXY: process.env.HTTPS_PROXY,
http_proxy: process.env.http_proxy,
HTTP_PROXY: process.env.HTTP_PROXY,
};
describe("getProxyUrl", () => {
afterEach(() => {
restoreProxyEnv();
_resetProxyReachabilityForTesting();
});
test("does not use an unreachable loopback proxy", async () => {
const port = await getClosedLoopbackPort();
clearProxyEnv();
process.env.https_proxy = `http://127.0.0.1:${port}`;
expect(getProxyUrl()).toBeUndefined();
expect(await _waitForProxyReachabilityForTesting()).toBe(false);
expect(process.env.https_proxy).toBeUndefined();
expect(getProxyUrl()).toBeUndefined();
});
test("uses a loopback proxy after reachability is confirmed", async () => {
const server = createServer();
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => resolve());
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected TCP server address");
}
const proxyUrl = `http://127.0.0.1:${address.port}`;
clearProxyEnv();
process.env.https_proxy = proxyUrl;
expect(getProxyUrl()).toBeUndefined();
expect(await _waitForProxyReachabilityForTesting()).toBe(true);
expect(getProxyUrl()).toBe(proxyUrl);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
test("clears invalid proxy URLs from the current process", () => {
clearProxyEnv();
process.env.https_proxy = "not-a-url";
expect(getProxyUrl()).toBeUndefined();
expect(process.env.https_proxy).toBeUndefined();
});
});
function clearProxyEnv(): void {
for (const key of Object.keys(originalProxyEnv)) {
delete process.env[key];
}
}
function restoreProxyEnv(): void {
for (const [key, value] of Object.entries(originalProxyEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
async function getClosedLoopbackPort(): Promise<number> {
const server = createServer();
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected TCP server address");
}
const port = address.port;
await new Promise<void>(resolve => server.close(() => resolve()));
return port;
}

View File

@ -29,14 +29,14 @@ export const getCACertificates = memoize((): string[] | undefined => {
const useSystemCA =
hasNodeOption('--use-system-ca') || hasNodeOption('--use-openssl-ca')
const extraCertsPath = process.env.NODE_EXTRA_CA_CERTS
const extraCert = readExtraCACert(process.env.NODE_EXTRA_CA_CERTS)
logForDebugging(
`CA certs: useSystemCA=${useSystemCA}, extraCertsPath=${extraCertsPath}`,
`CA certs: useSystemCA=${useSystemCA}, hasExtraCert=${extraCert !== undefined}`,
)
// If neither is set, return undefined (use runtime defaults, no override)
if (!useSystemCA && !extraCertsPath) {
if (!useSystemCA && !extraCert) {
return undefined
}
@ -61,7 +61,7 @@ export const getCACertificates = memoize((): string[] | undefined => {
logForDebugging(
`CA certs: Loaded ${certs.length} system CA certificates (--use-system-ca)`,
)
} else if (!getCACerts && !extraCertsPath) {
} else if (!getCACerts && !extraCert) {
// Under Node.js where getCACertificates doesn't exist and no extra certs,
// return undefined to let Node.js handle --use-system-ca natively.
logForDebugging(
@ -84,26 +84,52 @@ export const getCACertificates = memoize((): string[] | undefined => {
}
// Append extra certs from file
if (extraCertsPath) {
try {
const extraCert = getFsImplementation().readFileSync(extraCertsPath, {
encoding: 'utf8',
})
certs.push(extraCert)
logForDebugging(
`CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS (${extraCertsPath})`,
)
} catch (error) {
logForDebugging(
`CA certs: Failed to read NODE_EXTRA_CA_CERTS file (${extraCertsPath}): ${error}`,
{ level: 'error' },
)
}
if (extraCert) {
certs.push(extraCert)
logForDebugging(
'CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS',
)
}
return certs.length > 0 ? certs : undefined
})
export function validateExtraCACertsEnv(): void {
readExtraCACert(process.env.NODE_EXTRA_CA_CERTS)
}
function readExtraCACert(path: string | undefined): string | undefined {
if (!path) {
return undefined
}
try {
const stats = getFsImplementation().statSync(path)
if (!stats.isFile()) {
logForDebugging(
`CA certs: Ignoring NODE_EXTRA_CA_CERTS because it is not a regular file (${path})`,
{ level: 'error' },
)
clearInvalidExtraCACertsEnv(path)
return undefined
}
return getFsImplementation().readFileSync(path, { encoding: 'utf8' })
} catch (error) {
logForDebugging(
`CA certs: Ignoring unreadable NODE_EXTRA_CA_CERTS path (${path}): ${error}`,
{ level: 'error' },
)
clearInvalidExtraCACertsEnv(path)
return undefined
}
}
function clearInvalidExtraCACertsEnv(path: string): void {
if (process.env.NODE_EXTRA_CA_CERTS === path) {
delete process.env.NODE_EXTRA_CA_CERTS
}
}
/**
* Clear the CA certificates cache.
* Call this when environment variables that affect CA certs may have changed

View File

@ -7,6 +7,7 @@ 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'
@ -55,6 +56,17 @@ export function getAddressFamily(options: LookupOptions): 0 | 4 | 6 {
}
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
@ -62,7 +74,39 @@ type EnvLike = Record<string, string | undefined>
* @param env Environment variables to check (defaults to process.env for production use)
*/
export function getProxyUrl(env: EnvLike = process.env): string | undefined {
return env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY
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
}
/**
@ -327,6 +371,7 @@ 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) {
@ -367,18 +412,24 @@ export function configureGlobalAgents(): void {
return config
})
// 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),
)
// 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 (mtlsOptions.dispatcher) {
if (!isBunRuntime && mtlsOptions.dispatcher) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
mtlsOptions.dispatcher,
@ -424,3 +475,133 @@ 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
}