feat: 添加代理和 CA 证书功能改进及测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
79548c324e
commit
b39887b2d3
|
|
@ -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,
|
||||
|
|
|
|||
94
src/utils/__tests__/caCerts.test.ts
Normal file
94
src/utils/__tests__/caCerts.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
98
src/utils/__tests__/proxy.test.ts
Normal file
98
src/utils/__tests__/proxy.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user