mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-24 21:25:45 +00:00
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import tls from 'node:tls';
|
|
import type { Agent as HttpAgent } from 'node:http';
|
|
import type { Agent as HttpsAgent } from 'node:https';
|
|
import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent';
|
|
|
|
/**
|
|
* Agent cache for SSL session reuse.
|
|
* Agents are cached by their configuration to enable TLS session resumption,
|
|
* which significantly reduces SSL handshake time for repeated requests.
|
|
*/
|
|
const agentCache = new Map<string, HttpAgent | HttpsAgent>();
|
|
|
|
/**
|
|
* Maximum number of agents to cache.
|
|
* 100 provides a good balance between memory usage and SSL session reuse.
|
|
* Each agent maintains persistent connections, so higher values increase memory.
|
|
* Lower values may reduce SSL session hits for users with many different TLS configs.
|
|
*/
|
|
const MAX_AGENT_CACHE_SIZE = 100;
|
|
|
|
/**
|
|
* Cache for timeline-wrapped HTTPS agent classes.
|
|
* Prevents creating new class definitions on every call.
|
|
*/
|
|
const timelineClassCache = new WeakMap<any, AgentClass>();
|
|
|
|
/**
|
|
* Cache for timeline-wrapped HTTP agent classes.
|
|
* Prevents creating new class definitions on every call.
|
|
*/
|
|
const timelineHttpClassCache = new WeakMap<any, HttpAgentClass>();
|
|
|
|
/**
|
|
* Map to assign unique IDs to agent classes.
|
|
* Used for cache key generation since different classes may have the same name.
|
|
*/
|
|
const agentClassIdMap = new WeakMap<any, number>();
|
|
let agentClassIdCounter = 0;
|
|
|
|
function getAgentClassId(AgentClass: any): number {
|
|
if (agentClassIdMap.has(AgentClass)) {
|
|
return agentClassIdMap.get(AgentClass)!;
|
|
}
|
|
const id = ++agentClassIdCounter;
|
|
agentClassIdMap.set(AgentClass, id);
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Hash a value using SHA-256 and return a truncated hex string.
|
|
* Truncated to 16 chars for compact cache keys while maintaining uniqueness.
|
|
*/
|
|
function hashValue(value: string | Buffer | undefined): string | null {
|
|
if (!value) return null;
|
|
const data = Buffer.isBuffer(value) ? value : String(value);
|
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Cache for secure contexts created from CA options.
|
|
* Keyed by the hash of the CA value to avoid creating duplicate contexts.
|
|
*/
|
|
const secureContextCache = new Map<string, tls.SecureContext>();
|
|
|
|
/**
|
|
* Build a TLS secure context that adds custom CAs on top of the OpenSSL defaults.
|
|
*
|
|
* When Node.js receives an explicit `ca` option in tls.connect() or https.Agent,
|
|
* it replaces the default CA store entirely. This means CAs that are only in the
|
|
* OpenSSL default trust store (e.g. /etc/ssl/cert.pem) but not in
|
|
* tls.rootCertificates or tls.getCACertificates('system') are lost.
|
|
*
|
|
* This function creates a secureContext starting from the OpenSSL defaults
|
|
* and adds custom CAs on top via addCACert(), which appends rather than replaces.
|
|
*/
|
|
function buildSecureContext(ca: string | Buffer | (string | Buffer)[]): tls.SecureContext {
|
|
const caHash = hashCaValue(ca);
|
|
if (caHash && secureContextCache.has(caHash)) {
|
|
return secureContextCache.get(caHash)!;
|
|
}
|
|
|
|
const ctx = tls.createSecureContext();
|
|
const caList = Array.isArray(ca) ? ca : [ca];
|
|
for (const cert of caList) {
|
|
if (cert) {
|
|
ctx.context.addCACert(cert);
|
|
}
|
|
}
|
|
|
|
if (caHash) {
|
|
secureContextCache.set(caHash, ctx);
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* Convert agent options to use a secureContext instead of raw `ca`.
|
|
* This ensures custom CAs are added on top of the OpenSSL defaults
|
|
* rather than replacing the default CA store.
|
|
*
|
|
* When client certificates (pfx/cert/key) are also present, they are loaded
|
|
* into the secure context so they aren't silently ignored by Node.js
|
|
* (Node.js skips pfx/cert/key/ca when a secureContext is provided).
|
|
*/
|
|
function applySecureContext<T extends AgentOptions | HttpAgentOptions>(options: T): T {
|
|
if ('ca' in options && (options as AgentOptions).ca) {
|
|
const { ca, ...rest } = options as AgentOptions;
|
|
|
|
// When client certs are present alongside CA, build a combined context
|
|
// that includes both. This context can't be CA-cached since it's unique
|
|
// per client cert + CA combination.
|
|
const hasClientCert = rest.pfx || rest.cert || rest.key;
|
|
if (hasClientCert) {
|
|
const ctxOptions: Record<string, any> = {};
|
|
if (rest.pfx) ctxOptions.pfx = rest.pfx;
|
|
if (rest.cert) ctxOptions.cert = rest.cert;
|
|
if (rest.key) ctxOptions.key = rest.key;
|
|
if (rest.passphrase) ctxOptions.passphrase = rest.passphrase;
|
|
|
|
const ctx = tls.createSecureContext(ctxOptions);
|
|
const caList = Array.isArray(ca) ? ca : [ca!];
|
|
for (const caCert of caList) {
|
|
if (caCert) ctx.context.addCACert(caCert);
|
|
}
|
|
|
|
const { pfx: _pfx, cert: _cert, key: _key, passphrase: _pass, ...cleanRest } = rest;
|
|
return { ...cleanRest, secureContext: ctx } as unknown as T;
|
|
}
|
|
|
|
// CA-only case: use cached secure context
|
|
return { ...rest, secureContext: buildSecureContext(ca!) } as unknown as T;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* Hash a CA value which can be a single value or an array of certificates.
|
|
* Node.js TLS options allow ca to be string | Buffer | (string | Buffer)[].
|
|
*/
|
|
function hashCaValue(value: string | Buffer | (string | Buffer)[] | undefined): string | null {
|
|
if (!value) return null;
|
|
if (Array.isArray(value)) {
|
|
// Concatenate all values with separator and hash together
|
|
const combined = value.map((v) => (Buffer.isBuffer(v) ? v.toString('base64') : String(v))).join('|');
|
|
return crypto.createHash('sha256').update(combined).digest('hex').slice(0, 16);
|
|
}
|
|
const data = Buffer.isBuffer(value) ? value : String(value);
|
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Generate a cache key from HTTPS agent options.
|
|
* Uses a hash of the serialized options to create a compact key.
|
|
*/
|
|
function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
|
|
// Extract the TLS-relevant options for the cache key
|
|
const keyData = {
|
|
agentClassId,
|
|
hostname: proxyUri?.length ? null : hostname,
|
|
proxyUri,
|
|
keepAlive: options.keepAlive,
|
|
rejectUnauthorized: options.rejectUnauthorized,
|
|
// Hash certificates and passphrase instead of including full content
|
|
ca: hashCaValue(options.ca),
|
|
cert: hashValue(options.cert),
|
|
key: hashValue(options.key),
|
|
pfx: hashValue(options.pfx),
|
|
passphrase: hashValue(options.passphrase),
|
|
minVersion: options.minVersion,
|
|
secureProtocol: options.secureProtocol
|
|
};
|
|
return JSON.stringify(keyData);
|
|
}
|
|
|
|
/**
|
|
* Generate a cache key from HTTP agent options.
|
|
* Simpler than HTTPS since no TLS options are involved.
|
|
*/
|
|
function getHttpAgentCacheKey(agentClassId: number, options: HttpAgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
|
|
const keyData = {
|
|
agentClassId,
|
|
hostname: proxyUri?.length ? null : hostname,
|
|
proxyUri,
|
|
keepAlive: options.keepAlive
|
|
};
|
|
return JSON.stringify(keyData);
|
|
}
|
|
|
|
/**
|
|
* Get a cached timeline-wrapped HTTPS agent class.
|
|
* Creates the wrapped class once and caches it for reuse.
|
|
*/
|
|
function getTimelineAgentClass(BaseAgentClass: any): AgentClass {
|
|
if (timelineClassCache.has(BaseAgentClass)) {
|
|
return timelineClassCache.get(BaseAgentClass)!;
|
|
}
|
|
const wrappedClass = createTimelineAgentClass(BaseAgentClass);
|
|
timelineClassCache.set(BaseAgentClass, wrappedClass);
|
|
return wrappedClass;
|
|
}
|
|
|
|
/**
|
|
* Get a cached timeline-wrapped HTTP agent class.
|
|
* Creates the wrapped class once and caches it for reuse.
|
|
*/
|
|
function getTimelineHttpAgentClass(BaseAgentClass: any): HttpAgentClass {
|
|
if (timelineHttpClassCache.has(BaseAgentClass)) {
|
|
return timelineHttpClassCache.get(BaseAgentClass)!;
|
|
}
|
|
const wrappedClass = createTimelineHttpAgentClass(BaseAgentClass);
|
|
timelineHttpClassCache.set(BaseAgentClass, wrappedClass);
|
|
return wrappedClass;
|
|
}
|
|
|
|
/**
|
|
* Type for cache key generation functions.
|
|
*/
|
|
type CacheKeyFn<T> = (classId: number, options: T, proxyUri: string | null, hostname: string | null) => string;
|
|
|
|
/**
|
|
* Type for timeline class wrapper functions.
|
|
*/
|
|
type TimelineClassFn = (base: any) => AgentClass | HttpAgentClass;
|
|
|
|
/**
|
|
* Internal helper for agent caching with LRU eviction.
|
|
* Shared logic for both HTTP and HTTPS agents.
|
|
*/
|
|
function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
|
|
BaseAgentClass: any,
|
|
options: TOptions,
|
|
proxyUri: string | null,
|
|
timeline: TimelineEntry[] | null,
|
|
getCacheKey: CacheKeyFn<TOptions>,
|
|
getTimelineClass: TimelineClassFn,
|
|
cacheHitMessage: string,
|
|
disableCache: boolean = false,
|
|
hostname: string | null = null
|
|
): HttpAgent | HttpsAgent {
|
|
const agentClassId = getAgentClassId(BaseAgentClass);
|
|
const cacheKey = getCacheKey(agentClassId, options, proxyUri, hostname);
|
|
|
|
if (!disableCache && agentCache.has(cacheKey)) {
|
|
// Move to end for LRU (delete and re-add)
|
|
const agent = agentCache.get(cacheKey)!;
|
|
agentCache.delete(cacheKey);
|
|
agentCache.set(cacheKey, agent);
|
|
|
|
// Update timeline reference for new request
|
|
// The cached agent was created with a previous timeline,
|
|
// but we need events to go to the current request's timeline
|
|
if (timeline && 'timeline' in agent) {
|
|
(agent as any).timeline = timeline;
|
|
}
|
|
|
|
// Log that we're reusing a cached agent
|
|
if (timeline) {
|
|
timeline.push({
|
|
timestamp: new Date(),
|
|
type: 'info',
|
|
message: cacheHitMessage
|
|
});
|
|
}
|
|
|
|
return agent;
|
|
}
|
|
|
|
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
|
|
// Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
|
|
const resolvedOptions = applySecureContext(options);
|
|
|
|
let agent: HttpAgent | HttpsAgent;
|
|
if (timeline) {
|
|
// Timeline-wrapped classes handle proxy internally via options.proxy
|
|
const agentOptions = proxyUri ? { ...resolvedOptions, proxy: proxyUri } : resolvedOptions;
|
|
agent = new AgentClass(agentOptions, timeline);
|
|
} else if (proxyUri) {
|
|
// Proxy agent classes expect (proxyUri, options) constructor signature
|
|
agent = new BaseAgentClass(proxyUri, resolvedOptions);
|
|
} else {
|
|
agent = new BaseAgentClass(resolvedOptions);
|
|
}
|
|
|
|
if (!disableCache) {
|
|
// Evict oldest entry if cache is full (LRU eviction)
|
|
if (agentCache.size >= MAX_AGENT_CACHE_SIZE) {
|
|
const firstKey = agentCache.keys().next().value;
|
|
if (firstKey !== undefined) {
|
|
const evictedAgent = agentCache.get(firstKey);
|
|
agentCache.delete(firstKey);
|
|
// Destroy the agent to release its sockets and prevent memory leaks
|
|
if (evictedAgent && typeof (evictedAgent as any).destroy === 'function') {
|
|
(evictedAgent as any).destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
agentCache.set(cacheKey, agent);
|
|
}
|
|
|
|
return agent;
|
|
}
|
|
|
|
/**
|
|
* Get or create a cached HTTPS agent.
|
|
* Reuses existing agents to enable SSL session caching.
|
|
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
|
|
* Automatically wraps the agent class with timeline logging support.
|
|
*/
|
|
function getOrCreateHttpsAgent({
|
|
AgentClass,
|
|
options,
|
|
proxyUri = null,
|
|
timeline = null,
|
|
disableCache = false,
|
|
hostname = null
|
|
}: {
|
|
AgentClass: any;
|
|
options: AgentOptions;
|
|
proxyUri?: string | null;
|
|
timeline?: TimelineEntry[] | null;
|
|
disableCache?: boolean;
|
|
hostname?: string | null;
|
|
}): HttpAgent | HttpsAgent {
|
|
return getOrCreateAgentInternal(
|
|
AgentClass,
|
|
options,
|
|
proxyUri,
|
|
timeline,
|
|
getAgentCacheKey,
|
|
getTimelineAgentClass,
|
|
'Reusing cached https agent',
|
|
disableCache,
|
|
hostname
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get or create a cached HTTP agent.
|
|
* Reuses existing agents to enable connection reuse.
|
|
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
|
|
* Automatically wraps the agent class with timeline logging support.
|
|
*/
|
|
function getOrCreateHttpAgent({
|
|
AgentClass,
|
|
options,
|
|
proxyUri = null,
|
|
timeline = null,
|
|
disableCache = false,
|
|
hostname = null
|
|
}: {
|
|
AgentClass: any;
|
|
options: HttpAgentOptions;
|
|
proxyUri?: string | null;
|
|
timeline?: TimelineEntry[] | null;
|
|
disableCache?: boolean;
|
|
hostname?: string | null;
|
|
}): HttpAgent {
|
|
return getOrCreateAgentInternal(
|
|
AgentClass,
|
|
options,
|
|
proxyUri,
|
|
timeline,
|
|
getHttpAgentCacheKey,
|
|
getTimelineHttpAgentClass,
|
|
'Reusing cached http agent',
|
|
disableCache,
|
|
hostname
|
|
) as HttpAgent;
|
|
}
|
|
|
|
/**
|
|
* Clear the agent cache. Useful for testing or when SSL configuration changes.
|
|
* Destroys all cached agents to properly release their sockets.
|
|
*/
|
|
function clearAgentCache(): void {
|
|
for (const agent of agentCache.values()) {
|
|
if (agent && typeof (agent as any).destroy === 'function') {
|
|
(agent as any).destroy();
|
|
}
|
|
}
|
|
agentCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Get the current size of the agent cache.
|
|
*/
|
|
function getAgentCacheSize(): number {
|
|
return agentCache.size;
|
|
}
|
|
|
|
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize };
|