diff --git a/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js new file mode 100644 index 000000000..f19772f57 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.text}; + + form.bruno-form { + label { + font-size: 0.8125rem; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/Cache/index.js b/packages/bruno-app/src/components/Preferences/Cache/index.js new file mode 100644 index 000000000..28ef91483 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Cache/index.js @@ -0,0 +1,122 @@ +import React, { useEffect, useCallback, useRef } from 'react'; +import { useFormik } from 'formik'; +import { useSelector, useDispatch } from 'react-redux'; +import { + savePreferences, + clearHttpHttpsAgentCache +} from 'providers/ReduxStore/slices/app'; +import toast from 'react-hot-toast'; +import StyledWrapper from './StyledWrapper'; +import * as Yup from 'yup'; +import debounce from 'lodash/debounce'; +import get from 'lodash/get'; + +const cacheSchema = Yup.object().shape({ + sslSession: Yup.object({ + enabled: Yup.boolean() + }) +}); + +const Cache = () => { + const preferences = useSelector((state) => state.app.preferences); + const dispatch = useDispatch(); + + const handleSave = useCallback( + (newCachePreferences) => { + dispatch( + savePreferences({ + ...preferences, + cache: newCachePreferences + }) + ).catch(() => toast.error('Failed to update cache preferences')); + }, + [dispatch, preferences] + ); + + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + const formik = useFormik({ + initialValues: { + sslSession: { + enabled: get(preferences, 'cache.sslSession.enabled', false) + } + }, + validationSchema: cacheSchema, + onSubmit: async (values) => { + try { + const newPreferences = await cacheSchema.validate(values, { abortEarly: true }); + handleSave(newPreferences); + } catch (error) { + console.error('Cache preferences validation error:', error.message); + } + } + }); + + const debouncedSave = useCallback( + debounce((values) => { + cacheSchema + .validate(values, { abortEarly: true }) + .then((validatedValues) => handleSaveRef.current(validatedValues)) + .catch(() => {}); + }, 500), + [] + ); + + useEffect(() => { + if (formik.dirty && formik.isValid) { + debouncedSave(formik.values); + } + return () => { + debouncedSave.cancel(); + }; + }, [formik.values, formik.dirty, formik.isValid, debouncedSave]); + + const handleAgentCachingChange = (e) => { + formik.handleChange(e); + // Immediately evict all cached agents when caching is disabled + if (!e.target.checked) { + dispatch(clearHttpHttpsAgentCache()).catch(() => {}); + } + }; + + const handleResetCache = () => { + dispatch(clearHttpHttpsAgentCache()) + .then(() => toast.success('ssl session cache cleared')) + .catch(() => toast.error('Failed to clear ssl session cache')); + }; + + return ( + +
+
Cache SSL Session
+ +
+ + +
+
+ Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every + request. +
+ +
+ +
+
+
+ ); +}; + +export default Cache; diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js index 547ffd09a..88a17be43 100644 --- a/packages/bruno-app/src/components/Preferences/index.js +++ b/packages/bruno-app/src/components/Preferences/index.js @@ -9,7 +9,8 @@ import { IconUserCircle, IconKeyboard, IconZoomQuestion, - IconSquareLetterB + IconSquareLetterB, + IconDatabase } from '@tabler/icons'; import Support from './Support'; @@ -21,6 +22,7 @@ import Keybindings from './Keybindings'; import Beta from './Beta'; import StyledWrapper from './StyledWrapper'; +import Cache from './Cache/index'; const Preferences = () => { const dispatch = useDispatch(); @@ -65,6 +67,10 @@ const Preferences = () => { case 'support': { return ; } + + case 'cache': { + return ; + } } }; @@ -92,6 +98,10 @@ const Preferences = () => { Keybindings +
setTab('cache')}> + + Cache +
setTab('support')}> Support diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 9d91b166f..3d3c552f2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -43,6 +43,11 @@ const initialState = { autoSave: { enabled: false, interval: 1000 + }, + cache: { + sslSession: { + enabled: false + } } }, generateCode: { @@ -301,4 +306,11 @@ export const refreshSystemProxy = () => (dispatch, getState) => { }); }; +export const clearHttpHttpsAgentCache = () => () => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:clear-http-https-agent-cache').then(resolve).catch(reject); + }); +}; + export default appSlice.reducer; diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index bf5cc766d..2c20f7921 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -225,6 +225,11 @@ const builder = async (yargs) => { description: 'Disable all proxy settings (both collection-defined and system proxies)', default: false }) + .option('cache-ssl-session', { + type: 'boolean', + description: 'Enable SSL session caching — reuses TLS sessions across requests for faster handshakes', + default: false + }) .option('delay', { type: 'number', description: 'Delay between each requests (in miliseconds)' @@ -330,6 +335,7 @@ const handler = async function (argv) { reporterSkipBody, clientCertConfig, noproxy, + cacheSslSession, delay, tags: includeTags, excludeTags, @@ -531,6 +537,9 @@ const handler = async function (argv) { if (noproxy) { options['noproxy'] = true; } + if (cacheSslSession) { + options['cacheSslSession'] = true; + } if (verbose) { options['verbose'] = true; } diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index f0bd4f182..259a6f0fb 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -9,7 +9,8 @@ const { interpolateString, interpolateObject } = require('./interpolate-string') const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js'); const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); -const https = require('https'); +const https = require('node:https'); +const http = require('node:http'); const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { makeAxiosInstance } = require('../utils/axios-instance'); @@ -22,7 +23,7 @@ const { createFormData } = require('../utils/form-data'); const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests'); -const { getCACertificates, transformProxyConfig } = require('@usebruno/requests'); +const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests'); const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2'); const tokenStore = require('../store/tokenStore'); const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils; @@ -203,7 +204,8 @@ const runSingleRequest = async function ( shouldVerifyTls: !get(options, 'insecure', false), shouldUseCustomCaCertificate: !!options['cacert'], customCaCertificateFilePath: options['cacert'], - shouldKeepDefaultCaCertificates: !options['ignoreTruststore'] + shouldKeepDefaultCaCertificates: !options['ignoreTruststore'], + cacheSslSession: get(options, 'cacheSslSession', false) }, clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined, collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)), @@ -347,6 +349,7 @@ const runSingleRequest = async function ( const insecure = get(options, 'insecure', false); const noproxy = get(options, 'noproxy', false); const cachedSystemProxy = get(options, 'cachedSystemProxy', null); + const disableCache = !get(options, 'cacheSslSession', false); const httpsAgentRequestFields = {}; if (insecure) { @@ -426,6 +429,18 @@ const runSingleRequest = async function ( } // else: collection proxy is disabled, proxyMode stays 'off' + // Prepare TLS options for agent caching + const tlsOptions = { + ...httpsAgentRequestFields + }; + + // HTTP agent options — separate from tlsOptions to avoid leaking TLS fields + const httpAgentOptions = { keepAlive: true }; + + const parsedRequestUrl = new URL(request.url); + const isHttpsRequest = parsedRequestUrl.protocol === 'https:'; + const hostname = parsedRequestUrl.hostname || null; + if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', '')); if (shouldProxy) { @@ -444,35 +459,37 @@ const runSingleRequest = async function ( } else { proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } + // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options + // (e.g., ca certs) even for plain HTTP requests + const isHttpsProxy = proxyProtocol === 'https'; + const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions; + + // Only set the agent needed for the request protocol if (socksEnabled) { - request.httpsAgent = new SocksProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - request.httpAgent = new SocksProxyAgent(proxyUri); + if (isHttpsRequest) { + request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname }); + } else { + request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname }); + } } else { - request.httpsAgent = new PatchedHttpsProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - request.httpAgent = new HttpProxyAgent(proxyUri); + if (isHttpsRequest) { + request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname }); + } else { + request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname }); + } } - } else { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); } } else if (proxyMode === 'system') { try { const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {}; const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || ''); - const parsedUrl = new URL(request.url); - const isHttpsRequest = parsedUrl.protocol === 'https:'; if (shouldUseSystemProxy) { try { if (http_proxy?.length && !isHttpsRequest) { - new URL(http_proxy); - request.httpAgent = new HttpProxyAgent(http_proxy); + const parsedHttpProxy = new URL(http_proxy); + const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:'; + const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions; + request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname }); } } catch (error) { throw new Error('Invalid system http_proxy'); @@ -480,30 +497,21 @@ const runSingleRequest = async function ( try { if (https_proxy?.length && isHttpsRequest) { new URL(https_proxy); - request.httpsAgent = new PatchedHttpsProxyAgent(https_proxy, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined); - } else { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); + request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname }); } } catch (error) { throw new Error('Invalid system https_proxy'); } - } else { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); } - } catch (error) { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); + } catch (error) {} + } + + if (!request.httpAgent && !request.httpsAgent) { + if (isHttpsRequest) { + request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname }); + } else { + request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname }); } - } else if (Object.keys(httpsAgentRequestFields).length > 0) { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); } // set cookies if enabled @@ -610,12 +618,13 @@ const runSingleRequest = async function ( let token; if (oauth2RequestUrl) { - const tlsOptions = { + const oauth2ConfigOptions = { noproxy: options.noproxy, shouldVerifyTls: !insecure, shouldUseCustomCaCertificate: !!options['cacert'], customCaCertificateFilePath: options['cacert'], - shouldKeepDefaultCaCertificates: !options['ignoreTruststore'] + shouldKeepDefaultCaCertificates: !options['ignoreTruststore'], + cacheSslSession: !disableCache }; const clientCertificates = get(brunoConfig, 'clientCertificates'); @@ -627,7 +636,7 @@ const runSingleRequest = async function ( const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({ requestUrl: oauth2RequestUrl, collectionPath, - options: tlsOptions, + options: oauth2ConfigOptions, clientCertificates: interpolatedClientCertificates, collectionLevelProxy: interpolatedProxyConfig, systemProxyConfig diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js index 729e03356..70d1b4057 100644 --- a/packages/bruno-cli/src/utils/proxy-util.js +++ b/packages/bruno-cli/src/utils/proxy-util.js @@ -63,9 +63,17 @@ const shouldUseProxy = (url, proxyBypass) => { }; /** - * Patched version of HttpsProxyAgent to get around a bug that ignores - * options like ca and rejectUnauthorized when upgrading the socket to TLS: - * https://github.com/TooTallNate/proxy-agents/issues/194 + * Options that should be forwarded from the constructor to the target TLS upgrade. + */ +const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext']; + +/** + * Patched version of HttpsProxyAgent that correctly handles TLS options for + * both the proxy connection and the target server connection. + * + * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194) + * ignores constructor options when upgrading the tunneled socket to TLS for the + * target server. This patch forwards the relevant TLS options to the target upgrade. */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { constructor(proxy, opts) { @@ -74,8 +82,17 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect(req, opts) { - const combinedOpts = { ...this.constructorOpts, ...opts }; - return super.connect(req, combinedOpts); + const targetOpts = { ...opts }; + + if (this.constructorOpts) { + for (const key of TARGET_TLS_OPTIONS) { + if (key in this.constructorOpts) { + targetOpts[key] = this.constructorOpts[key]; + } + } + } + + return super.connect(req, targetOpts); } } diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js index ba71d894a..3fd45742c 100644 --- a/packages/bruno-electron/src/ipc/network/cert-utils.js +++ b/packages/bruno-electron/src/ipc/network/cert-utils.js @@ -194,7 +194,8 @@ const buildCertsAndProxyConfig = async ({ shouldVerifyTls: preferencesUtil.shouldVerifyTls(), shouldUseCustomCaCertificate: preferencesUtil.shouldUseCustomCaCertificate(), customCaCertificateFilePath: preferencesUtil.getCustomCaCertificateFilePath(), - shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates() + shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates(), + cacheSslSession: preferencesUtil.isSslSessionCachingEnabled() }; // Get client certificates from bruno config and interpolate diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js index 305e87895..8ec2f0fd2 100644 --- a/packages/bruno-electron/src/ipc/preferences.js +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -6,6 +6,7 @@ const { getCachedSystemProxy, fetchSystemProxy } = require('../store/system-prox const { resolveDefaultLocation } = require('../utils/default-location'); const onboardUser = require('../app/onboarding'); const LastOpenedCollections = require('../store/last-opened-collections'); +const { clearAgentCache } = require('@usebruno/requests'); const registerPreferencesIpc = (mainWindow) => { const lastOpenedCollections = new LastOpenedCollections(); @@ -56,6 +57,14 @@ const registerPreferencesIpc = (mainWindow) => { } }); + ipcMain.handle('renderer:clear-http-https-agent-cache', async () => { + try { + clearAgentCache(); + } catch (error) { + return Promise.reject(error); + } + }); + ipcMain.on('renderer:theme-change', (event, theme) => { nativeTheme.themeSource = theme; }); diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index a37d363a7..48ed9db2a 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -106,6 +106,11 @@ const defaultPreferences = { }, display: { zoomPercentage: 100 + }, + cache: { + sslSession: { + enabled: false + } } }; @@ -164,7 +169,12 @@ const preferencesSchema = Yup.object().shape({ }), display: Yup.object({ zoomPercentage: Yup.number().min(50).max(150) - }) + }), + cache: Yup.object({ + sslSession: Yup.object({ + enabled: Yup.boolean() + }) + }).optional() }); class PreferencesStore { @@ -351,6 +361,9 @@ const preferencesUtil = { getZoomPercentage: () => { return get(getPreferences(), 'display.zoomPercentage', 100); }, + isSslSessionCachingEnabled: () => { + return get(getPreferences(), 'cache.sslSession.enabled', false); + }, hasLaunchedBefore: () => { return get(getPreferences(), 'onboarding.hasLaunchedBefore', false); }, diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js index 5730968ac..bf922f80a 100644 --- a/packages/bruno-electron/src/utils/proxy-util.js +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -1,10 +1,13 @@ const parseUrl = require('url').parse; const https = require('node:https'); +const http = require('node:http'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { interpolateString } = require('../ipc/network/interpolate-string'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent'); const { isEmpty, get, isUndefined, isNull } = require('lodash'); +const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests'); +const { preferencesUtil } = require('../store/preferences'); const DEFAULT_PORTS = { ftp: 21, @@ -67,9 +70,17 @@ const shouldUseProxy = (url, proxyBypass) => { }; /** - * Patched version of HttpsProxyAgent to get around a bug that ignores options - * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: - * https://github.com/TooTallNate/proxy-agents/issues/194 + * Options that should be forwarded from the constructor to the target TLS upgrade. + */ +const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext']; + +/** + * Patched version of HttpsProxyAgent that correctly handles TLS options for + * both the proxy connection and the target server connection. + * + * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194) + * ignores constructor options when upgrading the tunneled socket to TLS for the + * target server. This patch forwards the relevant TLS options to the target upgrade. */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { constructor(proxy, opts) { @@ -78,244 +89,20 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect(req, opts) { - const combinedOpts = { ...this.constructorOpts, ...opts }; - return super.connect(req, combinedOpts); + const targetOpts = { ...opts }; + + if (this.constructorOpts) { + for (const key of TARGET_TLS_OPTIONS) { + if (key in this.constructorOpts) { + targetOpts[key] = this.constructorOpts[key]; + } + } + } + + return super.connect(req, targetOpts); } } -function createTimelineHttpAgentClass(BaseAgentClass) { - return class extends BaseAgentClass { - constructor(options, timeline) { - // For proxy agents, the first argument is the proxy URI and the second is options - const { proxy: proxyUri, httpProxyAgentOptions } = options || {}; - - if (!proxyUri) { - throw new Error('TimelineHttpProxyAgent requires options.proxy to be set'); - } - - super(proxyUri, httpProxyAgentOptions); - - this.timeline = Array.isArray(timeline) ? timeline : []; - // Log the proxy details - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `Using proxy: ${proxyUri}` - }); - } - }; -} - -function createTimelineAgentClass(BaseAgentClass) { - return class extends BaseAgentClass { - constructor(options, timeline) { - let caCertificatesCount = options.caCertificatesCount || {}; - delete options.caCertificatesCount; - - // For proxy agents, the first argument is the proxy URI and the second is options - if (options?.proxy) { - const { proxy: proxyUri, ...agentOptions } = options; - // Ensure TLS options are properly set - const tlsOptions = { - ...agentOptions, - rejectUnauthorized: agentOptions.rejectUnauthorized ?? true - }; - super(proxyUri, tlsOptions); - this.timeline = Array.isArray(timeline) ? timeline : []; - this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1']; - this.caProvided = !!tlsOptions.ca; - - // Log TLS verification status - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}` - }); - - // Log the proxy details - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `Using proxy: ${proxyUri}` - }); - } else { - // This is a regular HTTPS agent case - const tlsOptions = { - ...options, - rejectUnauthorized: options.rejectUnauthorized ?? true - }; - super(tlsOptions); - this.timeline = Array.isArray(timeline) ? timeline : []; - this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1']; - this.caProvided = !!options.ca; - - // Log TLS verification status - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}` - }); - } - - this.caCertificatesCount = caCertificatesCount; - } - - createConnection(options, callback) { - const { host, port } = options; - - // Log ALPN protocols offered - if (this.alpnProtocols && this.alpnProtocols.length > 0) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: `ALPN: offers ${this.alpnProtocols.join(', ')}` - }); - } - - const rootCerts = this.caCertificatesCount.root || 0; - const systemCerts = this.caCertificatesCount.system || 0; - const extraCerts = this.caCertificatesCount.extra || 0; - const customCerts = this.caCertificatesCount.custom || 0; - - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom` - }); - - // Log "Trying host:port..." - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `Trying ${host}:${port}...` - }); - - let socket; - try { - socket = super.createConnection(options, callback); - } catch (error) { - this.timeline.push({ - timestamp: new Date(), - type: 'error', - message: `Error creating connection: ${error.message}` - }); - error.timeline = this.timeline; - throw error; - } - - // Attach event listeners to the socket - socket?.on('lookup', (err, address, family, host) => { - if (err) { - this.timeline.push({ - timestamp: new Date(), - type: 'error', - message: `DNS lookup error for ${host}: ${err.message}` - }); - } else { - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `DNS lookup: ${host} -> ${address}` - }); - } - }); - - socket?.on('connect', () => { - const address = socket.remoteAddress || host; - const remotePort = socket.remotePort || port; - - this.timeline.push({ - timestamp: new Date(), - type: 'info', - message: `Connected to ${host} (${address}) port ${remotePort}` - }); - }); - - socket?.on('secureConnect', () => { - const protocol = socket.getProtocol() || 'SSL/TLS'; - const cipher = socket.getCipher(); - const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher'; - - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: `SSL connection using ${protocol} / ${cipherSuite}` - }); - - // ALPN protocol - const alpnProtocol = socket.alpnProtocol || 'None'; - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: `ALPN: server accepted ${alpnProtocol}` - }); - - // Server certificate - const cert = socket.getPeerCertificate(true); - if (cert) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: `Server certificate:` - }); - if (cert.subject) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}` - }); - } - if (cert.valid_from) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: ` start date: ${cert.valid_from}` - }); - } - if (cert.valid_to) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: ` expire date: ${cert.valid_to}` - }); - } - if (cert.subjectaltname) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: ` subjectAltName: ${cert.subjectaltname}` - }); - } - if (cert.issuer) { - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}` - }); - } - - // SSL certificate verify ok - this.timeline.push({ - timestamp: new Date(), - type: 'tls', - message: `SSL certificate verify ok.` - }); - } - }); - - socket?.on('error', (err) => { - this.timeline.push({ - timestamp: new Date(), - type: 'error', - message: `Socket error: ${err.message}` - }); - }); - - return socket; - } - }; -} - function setupProxyAgents({ requestConfig, proxyMode = 'off', @@ -324,6 +111,8 @@ function setupProxyAgents({ interpolationOptions, timeline }) { + const disableCache = !preferencesUtil.isSslSessionCachingEnabled(); + // Ensure TLS options are properly set const tlsOptions = { ...httpsAgentRequestFields, @@ -331,21 +120,22 @@ function setupProxyAgents({ secureProtocol: undefined, // Allow Node.js to choose the protocol minVersion: 'TLSv1', - rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true - }; - - const httpProxyAgentOptions = { + rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true, + // Enable keepAlive for connection reuse keepAlive: true }; + const parsedUrl = parseUrl(requestConfig.url); + const isHttpsRequest = parsedUrl.protocol === 'https:'; + const hostname = parsedUrl.hostname || null; + if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', '')); if (shouldProxy) { const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); - const proxyAuthDisabled = get(proxyConfig, 'auth.disabled', false); - const proxyAuthEnabled = !proxyAuthDisabled; + const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false); const socksEnabled = proxyProtocol.includes('socks'); let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; @@ -358,35 +148,51 @@ function setupProxyAgents({ proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } - if (socksEnabled) { - const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent); - requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline); - requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline); - } else { - const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent); - const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent); - requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: proxyUri, httpProxyAgentOptions }, timeline); - requestConfig.httpsAgent = new TimelineHttpsProxyAgent( - { proxy: proxyUri, ...tlsOptions }, - timeline - ); + if (timeline) { + timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Using proxy: ${proxyProtocol}://${proxyHostname}${uriPort}` + }); + } + + // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options + // (e.g., ca certs) even for plain HTTP requests + const isHttpsProxy = proxyProtocol === 'https'; + const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + + // Only set the agent needed for the request protocol + if (socksEnabled) { + if (isHttpsRequest) { + requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname }); + } else { + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname }); + } + } else { + if (isHttpsRequest) { + requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname }); + } else { + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname }); + } } - } else { - // If proxy should not be used, set default HTTPS agent - const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); - requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } else if (proxyMode === 'system') { const { http_proxy, https_proxy, no_proxy } = proxyConfig || {}; const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || ''); - const parsedUrl = parseUrl(requestConfig.url); - const isHttpsRequest = parsedUrl.protocol === 'https:'; if (shouldUseSystemProxy) { try { if (http_proxy?.length && !isHttpsRequest) { - new URL(http_proxy); - const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent); - requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: http_proxy, httpProxyAgentOptions }, timeline); + const parsedHttpProxy = new URL(http_proxy); + const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:'; + const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + if (timeline) { + timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Using system proxy: ${http_proxy}` + }); + } + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname }); } } catch (error) { throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`); @@ -394,25 +200,27 @@ function setupProxyAgents({ try { if (https_proxy?.length && isHttpsRequest) { new URL(https_proxy); - const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent); - requestConfig.httpsAgent = new TimelineHttpsProxyAgent( - { proxy: https_proxy, ...tlsOptions }, - timeline - ); - } else { - const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); - requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); + if (timeline) { + timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Using system proxy: ${https_proxy}` + }); + } + requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname }); } } catch (error) { throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`); } - } else { - const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); - requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } - } else { - const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); - requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); + } + + if (!requestConfig.httpAgent && !requestConfig.httpsAgent) { + if (isHttpsRequest) { + requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, proxyUri: null, timeline, disableCache, hostname }); + } else { + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, proxyUri: null, timeline, disableCache, hostname }); + } } } diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index dd728899c..03f2c6fed 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -9,6 +9,7 @@ export { default as createVaultClient, VaultError } from './utils/node-vault'; export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault'; export { getHttpHttpsAgents } from './utils/http-https-agents'; export { initializeShellEnv } from './utils/shell-env'; +export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache'; export * as scripting from './scripting'; diff --git a/packages/bruno-requests/src/utils/agent-cache.spec.ts b/packages/bruno-requests/src/utils/agent-cache.spec.ts new file mode 100644 index 000000000..ba19c60e6 --- /dev/null +++ b/packages/bruno-requests/src/utils/agent-cache.spec.ts @@ -0,0 +1,374 @@ +import https from 'node:https'; +import http from 'node:http'; +import { EventEmitter } from 'node:events'; +import { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './agent-cache'; + +describe('Agent Cache', () => { + beforeEach(() => { + clearAgentCache(); + }); + + describe('getOrCreateHttpsAgent', () => { + it('creates a new agent when cache is empty', () => { + const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } }); + + expect(agent).toBeInstanceOf(https.Agent); + expect(getAgentCacheSize()).toBe(1); + }); + + it('returns cached agent for identical options', () => { + const options = { rejectUnauthorized: true, keepAlive: true }; + + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options }); + + expect(agent1).toBe(agent2); + expect(getAgentCacheSize()).toBe(1); + }); + + it('creates separate agents for different rejectUnauthorized values', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different CA certificates', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-a' } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-b' } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different cert values', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-a') } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-b') } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different key values', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-a') } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-b') } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different pfx values', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-a') } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-b') } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different passphrase values', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-a' } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-b' } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different proxy URIs', () => { + const options = { rejectUnauthorized: true }; + + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy1:8080' }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy2:8080' }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different agent classes', () => { + const options = { keepAlive: true }; + + const httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options }); + const httpAgent = getOrCreateHttpsAgent({ AgentClass: http.Agent, options }); + + expect(httpsAgent).not.toBe(httpAgent); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different keepAlive values', () => { + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: true } }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: false } }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('creates separate agents for different hostnames', () => { + const options = { rejectUnauthorized: true }; + + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'auth.example.com' }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + + it('returns cached agent for the same hostname', () => { + const options = { rejectUnauthorized: true }; + + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' }); + + expect(agent1).toBe(agent2); + expect(getAgentCacheSize()).toBe(1); + }); + + it('creates separate agents for null hostname vs explicit hostname', () => { + const options = { rejectUnauthorized: true }; + + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: null }); + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' }); + + expect(agent1).not.toBe(agent2); + expect(getAgentCacheSize()).toBe(2); + }); + }); + + describe('timeline support', () => { + it('does not add timeline when none is provided', () => { + const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {} }) as any; + + expect(agent.timeline).toBeUndefined(); + }); + + it('uses provided timeline array', () => { + const timeline: any[] = []; + const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline }) as any; + + expect(agent.timeline).toBe(timeline); + }); + + it('updates timeline reference on cached agents', () => { + const timeline1: any[] = []; + const timeline2: any[] = []; + + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any; + expect(agent1.timeline).toBe(timeline1); + + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any; + expect(agent1).toBe(agent2); + expect(agent2.timeline).toBe(timeline2); + }); + + it('logs when reusing a cached HTTPS agent', () => { + const timeline1: any[] = []; + const timeline2: any[] = []; + + // First call creates new agent - no reuse message + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }); + expect(timeline1.some((e) => e.message.includes('Reusing cached https agent'))).toBe(false); + + // Second call reuses cached agent - should log reuse message with SSL session reuse + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }); + expect(timeline2.some((e) => e.message.includes('Reusing cached https agent'))).toBe(true); + }); + + it('logs when reusing a cached HTTP agent', () => { + const timeline1: any[] = []; + const timeline2: any[] = []; + + // First call creates new agent - no reuse message + getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline1 }); + expect(timeline1.some((e) => e.message.includes('Reusing cached http agent'))).toBe(false); + + // Second call reuses cached agent - should log reuse message with connection reuse + getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline2 }); + expect(timeline2.some((e) => e.message.includes('Reusing cached http agent'))).toBe(true); + }); + + it('logs SSL validation status on agent creation', () => { + const timeline: any[] = []; + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true }, timeline }); + + const sslEntry = timeline.find((e) => e.message.includes('SSL validation')); + expect(sslEntry).toBeDefined(); + expect(sslEntry.message).toContain('enabled'); + }); + + it('logs SSL validation disabled when rejectUnauthorized is false', () => { + const timeline: any[] = []; + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false }, timeline }); + + const sslEntry = timeline.find((e) => e.message.includes('SSL validation')); + expect(sslEntry).toBeDefined(); + expect(sslEntry.message).toContain('disabled'); + }); + }); + + describe('clearAgentCache', () => { + it('removes all cached agents', () => { + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } }); + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } }); + expect(getAgentCacheSize()).toBe(2); + + clearAgentCache(); + expect(getAgentCacheSize()).toBe(0); + }); + + it('destroys all agents when clearing cache', () => { + const destroyMocks: jest.Mock[] = []; + + // Create several agents and attach mock destroy functions + for (let i = 0; i < 5; i++) { + const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }) as any; + const mock = jest.fn(); + agent.destroy = mock; + destroyMocks.push(mock); + } + + expect(getAgentCacheSize()).toBe(5); + + clearAgentCache(); + + expect(getAgentCacheSize()).toBe(0); + // All agents should have been destroyed + destroyMocks.forEach((mock) => { + expect(mock).toHaveBeenCalled(); + }); + }); + }); + + describe('LRU eviction', () => { + it('maintains cache size under limit', () => { + // Create many agents with different options + for (let i = 0; i < 150; i++) { + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }); + } + + // Cache should be capped at MAX_AGENT_CACHE_SIZE (100) + expect(getAgentCacheSize()).toBeLessThanOrEqual(100); + }); + + it('destroys evicted agents to prevent memory leaks', () => { + // Create first agent and attach a mock destroy function + const firstAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-to-evict' } }) as any; + const destroyMock = jest.fn(); + firstAgent.destroy = destroyMock; + + // Fill cache to trigger eviction (100 more agents will evict the first one) + for (let i = 0; i < 100; i++) { + getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }); + } + + // First agent should have been evicted and destroyed + expect(destroyMock).toHaveBeenCalled(); + }); + }); + + describe('concurrent requests timeline isolation', () => { + it('isolates timeline events for concurrent requests using the same cached agent', () => { + const timeline1: any[] = []; + const timeline2: any[] = []; + + // Get the same agent twice with different timelines (simulating concurrent requests) + const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any; + const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any; + + // Both should return the same cached agent + expect(agent1).toBe(agent2); + + // Create mock sockets to simulate concurrent connections + const mockSocket1 = new EventEmitter() as any; + mockSocket1.remoteAddress = '1.2.3.4'; + mockSocket1.remotePort = 443; + mockSocket1.getProtocol = () => 'TLSv1.3'; + mockSocket1.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' }); + mockSocket1.alpnProtocol = 'h2'; + mockSocket1.getPeerCertificate = () => ({ + subject: { CN: 'example.com' }, + valid_from: 'Jan 1 00:00:00 2024 GMT', + valid_to: 'Jan 1 00:00:00 2025 GMT' + }); + mockSocket1.authorized = true; + + const mockSocket2 = new EventEmitter() as any; + mockSocket2.remoteAddress = '5.6.7.8'; + mockSocket2.remotePort = 443; + mockSocket2.getProtocol = () => 'TLSv1.3'; + mockSocket2.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' }); + mockSocket2.alpnProtocol = 'http/1.1'; + mockSocket2.getPeerCertificate = () => ({ + subject: { CN: 'other.com' }, + valid_from: 'Jan 1 00:00:00 2024 GMT', + valid_to: 'Jan 1 00:00:00 2025 GMT' + }); + mockSocket2.authorized = true; + + // Mock createConnection to return our mock sockets + const originalCreateConnection = Object.getPrototypeOf(Object.getPrototypeOf(agent1)).createConnection; + let callCount = 0; + jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent1)), 'createConnection').mockImplementation(function (this: any, options: any, callback: any) { + callCount++; + return callCount === 1 ? mockSocket1 : mockSocket2; + }); + + // Simulate request 1 starting - this captures timeline1 in the closure + agent1.timeline = timeline1; + const socket1 = agent1.createConnection({ host: 'example.com', port: 443 }, () => {}); + + // Before request 1's events fire, request 2 starts and updates agent.timeline + // This simulates the race condition + agent1.timeline = timeline2; + const socket2 = agent1.createConnection({ host: 'other.com', port: 443 }, () => {}); + + // Now fire events for both sockets - they should go to their respective timelines + mockSocket1.emit('connect'); + mockSocket1.emit('secureConnect'); + + mockSocket2.emit('connect'); + mockSocket2.emit('secureConnect'); + + // Verify timeline1 only contains events for request 1 (example.com) + const timeline1Messages = timeline1.map((e) => e.message); + expect(timeline1Messages.some((m) => m.includes('example.com'))).toBe(true); + expect(timeline1Messages.some((m) => m.includes('other.com'))).toBe(false); + + // Verify timeline2 only contains events for request 2 (other.com) + const timeline2Messages = timeline2.map((e) => e.message); + expect(timeline2Messages.some((m) => m.includes('other.com'))).toBe(true); + expect(timeline2Messages.some((m) => m.includes('example.com'))).toBe(false); + + // Restore the original implementation + jest.restoreAllMocks(); + }); + + it('logs events to captured timeline even after agent.timeline is reassigned', () => { + const timeline1: any[] = []; + const timeline2: any[] = []; + + const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any; + + // Create a mock socket + const mockSocket = new EventEmitter() as any; + mockSocket.remoteAddress = '1.2.3.4'; + mockSocket.remotePort = 443; + + // Mock createConnection + jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent)), 'createConnection').mockImplementation(() => mockSocket); + + // Start creating connection - this captures timeline1 + const socket = agent.createConnection({ host: 'test.com', port: 443 }, () => {}); + + // Reassign agent.timeline (simulating another request coming in) + agent.timeline = timeline2; + + // Fire the connect event - this should still go to timeline1 (captured reference) + mockSocket.emit('connect'); + + // Verify event went to timeline1, not timeline2 + expect(timeline1.some((e) => e.message.includes('Connected to test.com'))).toBe(true); + expect(timeline2.some((e) => e.message.includes('Connected to test.com'))).toBe(false); + + jest.restoreAllMocks(); + }); + }); +}); diff --git a/packages/bruno-requests/src/utils/agent-cache.ts b/packages/bruno-requests/src/utils/agent-cache.ts new file mode 100644 index 000000000..c7dc40105 --- /dev/null +++ b/packages/bruno-requests/src/utils/agent-cache.ts @@ -0,0 +1,393 @@ +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(); + +/** + * 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(); + +/** + * Cache for timeline-wrapped HTTP agent classes. + * Prevents creating new class definitions on every call. + */ +const timelineHttpClassCache = new WeakMap(); + +/** + * 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(); +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(); + +/** + * 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(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 = {}; + 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 = (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( + BaseAgentClass: any, + options: TOptions, + proxyUri: string | null, + timeline: TimelineEntry[] | null, + getCacheKey: CacheKeyFn, + 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 }; diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts index 8671779a2..3ea8969db 100644 --- a/packages/bruno-requests/src/utils/http-https-agents.ts +++ b/packages/bruno-requests/src/utils/http-https-agents.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import http from 'node:http'; import https from 'node:https'; import type { Agent as HttpAgent } from 'node:http'; import type { Agent as HttpsAgent } from 'node:https'; @@ -10,6 +11,8 @@ import { HttpProxyAgent } from 'http-proxy-agent'; import { isEmpty, get, isUndefined, isNull } from 'lodash'; import { getCACertificates } from './ca-cert'; import { transformProxyConfig } from './proxy-util'; +import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache'; +import type { TimelineEntry } from './timeline-agent'; const DEFAULT_PORTS: Record = { ftp: 21, @@ -93,6 +96,7 @@ type ConfigOptions = { shouldUseCustomCaCertificate: boolean; customCaCertificateFilePath?: string; shouldKeepDefaultCaCertificates: boolean; + cacheSslSession?: boolean; }; type GetCertsAndProxyConfigParams = { @@ -120,6 +124,8 @@ type CreateAgentsParams = { certsConfig: CertsConfig; httpsAgentRequestFields: HttpsAgentRequestFields; systemProxyConfig?: SystemProxyConfig; + timeline?: TimelineEntry[]; + disableCache?: boolean; }; type GetHttpHttpsAgentsParams = { @@ -132,6 +138,7 @@ type GetHttpHttpsAgentsParams = { collectionLevelProxy?: ProxyConfig; appLevelProxyConfig?: Record; systemProxyConfig?: SystemProxyConfig; + timeline?: TimelineEntry[]; }; /** @@ -188,9 +195,21 @@ const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined }; /** - * Patched version of HttpsProxyAgent to get around a bug that ignores options - * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: - * https://github.com/TooTallNate/proxy-agents/issues/194 + * Options that should be forwarded from the constructor to the target TLS upgrade. + * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194) + * ignores constructor options when upgrading the tunneled socket to TLS for the + * target server. This list covers client certificates, verification, and secure context. + */ +const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'] as const; + +/** + * Patched version of HttpsProxyAgent that correctly handles TLS options for + * both the proxy connection and the target server connection. + * + * This patch forwards client certificate options, rejectUnauthorized, and + * secureContext to the target TLS upgrade. The agent-cache layer converts raw + * `ca` to a secureContext (via addCACert) before construction, so custom CAs + * are added on top of the OpenSSL defaults rather than replacing them. */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { private constructorOpts: any; @@ -201,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect(req: any, opts: any) { - const combinedOpts = { ...this.constructorOpts, ...opts }; - return super.connect(req, combinedOpts); + const targetOpts = { ...opts }; + + // Forward TLS options to the target TLS upgrade + if (this.constructorOpts) { + for (const key of TARGET_TLS_OPTIONS) { + if (key in this.constructorOpts) { + targetOpts[key] = this.constructorOpts[key]; + } + } + } + + return super.connect(req, targetOpts); } } @@ -336,13 +365,24 @@ const getCertsAndProxyConfig = ({ return { proxyMode, proxyConfig, certsConfig }; }; +function extractHostname(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname || null; + } catch { + return null; + } +} + function createAgents({ requestUrl, proxyMode, proxyConfig, systemProxyConfig, certsConfig, - httpsAgentRequestFields + httpsAgentRequestFields, + timeline, + disableCache = true }: CreateAgentsParams): AgentResult { // Ensure TLS options are properly set const tlsOptions: TlsOptions = { @@ -358,13 +398,19 @@ function createAgents({ let httpAgent: HttpAgent | undefined; let httpsAgent: HttpsAgent | HttpsProxyAgent | SocksProxyAgent | undefined; + // Determine if this is an HTTPS request + const isHttpsRequest = requestUrl ? requestUrl.startsWith('https:') : true; + + // Extract hostname for per-host agent caching (enables TLS session reuse per host) + const hostname = extractHostname(requestUrl); + if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', '')); if (shouldProxy) { const proxyProtocol = get(proxyConfig, 'protocol'); const proxyHostname = get(proxyConfig, 'hostname'); const proxyPort = get(proxyConfig, 'port'); - const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false); const socksEnabled = proxyProtocol && proxyProtocol.includes('socks'); if (!proxyProtocol || !proxyHostname) { @@ -381,16 +427,31 @@ function createAgents({ proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } + // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options + // (e.g., ca certs) even for plain HTTP requests + const isHttpsProxy = proxyProtocol === 'https'; + const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + + // Only set the agent needed for the request protocol if (socksEnabled) { - httpAgent = new SocksProxyAgent(proxyUri); - httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any); + if (isHttpsRequest) { + httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; + } else { + httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }); + } } else { - httpAgent = new HttpProxyAgent(proxyUri); - httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions); + if (isHttpsRequest) { + httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; + } else { + httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }); + } } } else { - // If proxy should not be used, set default HTTPS agent - httpsAgent = new https.Agent(tlsOptions as any); + // If proxy should not be used, only set HTTPS agent for HTTPS requests + if (isHttpsRequest) { + httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; + } + // HTTP requests without proxy don't need a custom agent } } else if (proxyMode === 'system') { const http_proxy = get(systemProxyConfig, 'http_proxy'); @@ -399,28 +460,32 @@ function createAgents({ const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || ''); if (shouldUseSystemProxy) { try { - if (http_proxy?.length) { - new URL(http_proxy); - httpAgent = new HttpProxyAgent(http_proxy); + if (http_proxy?.length && !isHttpsRequest) { + const parsedHttpProxy = new URL(http_proxy); + const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:'; + const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname }); } } catch (error) { throw new Error('Invalid system http_proxy'); } try { - if (https_proxy?.length) { + if (https_proxy?.length && isHttpsRequest) { new URL(https_proxy); - httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any); - } else { - httpsAgent = new https.Agent(tlsOptions as any); + httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; } } catch (error) { throw new Error('Invalid system https_proxy'); } - } else { - httpsAgent = new https.Agent(tlsOptions as any); } - } else { - httpsAgent = new https.Agent(tlsOptions as any); + } + + if (!httpAgent && !httpsAgent) { + if (isHttpsRequest) { + httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; + } else { + httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline || null, disableCache, hostname }); + } } return { httpAgent, httpsAgent }; @@ -433,7 +498,8 @@ const getHttpHttpsAgents = async ({ collectionLevelProxy, appLevelProxyConfig, systemProxyConfig, - options + options, + timeline }: GetHttpHttpsAgentsParams): Promise => { const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({ requestUrl, @@ -460,7 +526,9 @@ const getHttpHttpsAgents = async ({ proxyConfig, systemProxyConfig, certsConfig, - httpsAgentRequestFields + httpsAgentRequestFields, + timeline, + disableCache: !options.cacheSslSession }); return { httpAgent, httpsAgent }; diff --git a/packages/bruno-requests/src/utils/timeline-agent.ts b/packages/bruno-requests/src/utils/timeline-agent.ts new file mode 100644 index 000000000..e1d9e1a84 --- /dev/null +++ b/packages/bruno-requests/src/utils/timeline-agent.ts @@ -0,0 +1,309 @@ +import http from 'node:http'; +import https from 'node:https'; + +type TimelineEntry = { + timestamp: Date; + type: 'info' | 'tls' | 'error'; + message: string; +}; + +type CaCertificatesCount = { + root?: number; + system?: number; + extra?: number; + custom?: number; +}; + +type AgentOptions = { + rejectUnauthorized?: boolean; + ca?: string | string[] | Buffer | Buffer[]; + cert?: string | Buffer; + key?: string | Buffer; + pfx?: string | Buffer; + passphrase?: string; + minVersion?: string; + secureProtocol?: string; + keepAlive?: boolean; + ALPNProtocols?: string[]; + caCertificatesCount?: CaCertificatesCount; + proxy?: string; + secureContext?: any; +}; + +type AgentClass = new (options: AgentOptions, timeline?: TimelineEntry[]) => https.Agent; +type ProxyAgentClass = new (proxyUri: string, options?: AgentOptions) => https.Agent; + +type HttpAgentOptions = { + keepAlive?: boolean; + proxy?: string; +}; + +type HttpAgentClass = new (options: HttpAgentOptions, timeline?: TimelineEntry[]) => http.Agent; +type HttpProxyAgentClass = new (proxyUri: string, options?: HttpAgentOptions) => http.Agent; + +/** + * Creates a timeline-aware agent class that logs TLS connection events. + * The returned class wraps the base agent and adds timeline logging for: + * - SSL validation status + * - Proxy usage + * - ALPN protocol negotiation + * - CA certificates info + * - DNS lookups + * - Connection establishment + * - TLS handshake details + * - Server certificate info + */ +function createTimelineAgentClass(BaseAgentClass: T): AgentClass { + return class TimelineAgent extends (BaseAgentClass as any) { + timeline: TimelineEntry[]; + alpnProtocols: string[]; + caProvided: boolean; + caCertificatesCount: CaCertificatesCount; + + /** + * Helper method to log entries to the timeline. + */ + private log(type: 'info' | 'tls' | 'error', message: string): void { + this.timeline.push({ + timestamp: new Date(), + type, + message + }); + } + + constructor(options: AgentOptions, timeline?: TimelineEntry[]) { + const caCertificatesCount = options.caCertificatesCount || {}; + const optionsCopy = { ...options }; + delete optionsCopy.caCertificatesCount; + + // For proxy agents, the first argument is the proxy URI and the second is options + if (optionsCopy?.proxy) { + const { proxy: proxyUri, ...agentOptions } = optionsCopy; + // Ensure TLS options are properly set + const tlsOptions = { + ...agentOptions, + rejectUnauthorized: agentOptions.rejectUnauthorized ?? true + }; + super(proxyUri, tlsOptions); + this.timeline = Array.isArray(timeline) ? timeline : []; + this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1']; + this.caProvided = !!(tlsOptions.ca || tlsOptions.secureContext); + + // Log TLS verification status and proxy details + this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`); + this.log('info', `Using proxy: ${proxyUri}`); + } else { + // This is a regular HTTPS agent case + const tlsOptions = { + ...optionsCopy, + rejectUnauthorized: optionsCopy.rejectUnauthorized ?? true + }; + super(tlsOptions); + this.timeline = Array.isArray(timeline) ? timeline : []; + this.alpnProtocols = optionsCopy.ALPNProtocols || ['h2', 'http/1.1']; + this.caProvided = !!(optionsCopy.ca || optionsCopy.secureContext); + + // Log TLS verification status + this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`); + } + + this.caCertificatesCount = caCertificatesCount; + } + + createConnection(options: any, callback: any) { + const { host, port } = options; + + // Capture the current timeline reference to avoid race conditions + // when multiple concurrent requests reuse the same cached agent + const timeline = this.timeline; + const log = (type: 'info' | 'tls' | 'error', message: string): void => { + timeline.push({ + timestamp: new Date(), + type, + message + }); + }; + + // Log ALPN protocols offered + if (this.alpnProtocols && this.alpnProtocols.length > 0) { + log('tls', `ALPN: offers ${this.alpnProtocols.join(', ')}`); + } + + const rootCerts = this.caCertificatesCount.root || 0; + const systemCerts = this.caCertificatesCount.system || 0; + const extraCerts = this.caCertificatesCount.extra || 0; + const customCerts = this.caCertificatesCount.custom || 0; + + log('tls', `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`); + + // Log "Trying host:port..." + log('info', `Trying ${host}:${port}...`); + + let socket: any; + try { + socket = super.createConnection(options, callback); + } catch (error: any) { + log('error', `Error creating connection: ${error.message}`); + error.timeline = timeline; + throw error; + } + + // Attach event listeners to the socket + socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => { + if (err) { + log('error', `DNS lookup error for ${host}: ${err.message}`); + } else { + log('info', `DNS lookup: ${host} -> ${address}`); + } + }); + + socket?.on('connect', () => { + const address = socket.remoteAddress || host; + const remotePort = socket.remotePort || port; + + log('info', `Connected to ${host} (${address}) port ${remotePort}`); + }); + + socket?.on('secureConnect', () => { + const protocol = socket.getProtocol?.() || 'SSL/TLS'; + const cipher = socket.getCipher?.(); + const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher'; + + log('tls', `SSL connection using ${protocol} / ${cipherSuite}`); + + // ALPN protocol + const alpnProtocol = socket.alpnProtocol || 'None'; + log('tls', `ALPN: server accepted ${alpnProtocol}`); + + // Server certificate + const cert = socket.getPeerCertificate?.(true); + if (cert) { + log('tls', `Server certificate:`); + if (cert.subject) { + log('tls', ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`); + } + if (cert.valid_from) { + log('tls', ` start date: ${cert.valid_from}`); + } + if (cert.valid_to) { + log('tls', ` expire date: ${cert.valid_to}`); + } + if (cert.subjectaltname) { + log('tls', ` subjectAltName: ${cert.subjectaltname}`); + } + if (cert.issuer) { + log('tls', ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`); + } + + // SSL certificate verification status + if (socket.authorized !== false) { + log('tls', `SSL certificate verify ok.`); + } else { + log('tls', `SSL certificate verification skipped (rejectUnauthorized: false).`); + } + } + }); + + socket?.on('error', (err: Error) => { + log('error', `Socket error: ${err.message}`); + }); + + return socket; + } + } as unknown as AgentClass; +} + +/** + * Creates a timeline-aware HTTP agent class that logs connection events. + * The returned class wraps the base HTTP agent and adds timeline logging for: + * - Proxy usage (when applicable) + * - DNS lookups + * - Connection establishment + * - Errors + * + * This is a simplified version of createTimelineAgentClass for HTTP (non-TLS) connections. + */ +function createTimelineHttpAgentClass(BaseAgentClass: T): HttpAgentClass { + return class TimelineHttpAgent extends (BaseAgentClass as any) { + timeline: TimelineEntry[]; + + /** + * Helper method to log entries to the timeline. + */ + private log(type: 'info' | 'tls' | 'error', message: string): void { + this.timeline.push({ + timestamp: new Date(), + type, + message + }); + } + + constructor(options: HttpAgentOptions, timeline?: TimelineEntry[]) { + const optionsCopy = { ...options }; + + // For proxy agents, the first argument is the proxy URI and the second is options + if (optionsCopy?.proxy) { + const { proxy: proxyUri, ...agentOptions } = optionsCopy; + super(proxyUri, agentOptions); + this.timeline = Array.isArray(timeline) ? timeline : []; + + // Log proxy details + this.log('info', `Using proxy: ${proxyUri}`); + } else { + super(optionsCopy); + this.timeline = Array.isArray(timeline) ? timeline : []; + } + } + + createConnection(options: any, callback: any) { + const { host, port } = options; + + // Capture the current timeline reference to avoid race conditions + // when multiple concurrent requests reuse the same cached agent + const timeline = this.timeline; + const log = (type: 'info' | 'tls' | 'error', message: string): void => { + timeline.push({ + timestamp: new Date(), + type, + message + }); + }; + + // Log "Trying host:port..." + log('info', `Trying ${host}:${port}...`); + + let socket: any; + try { + socket = super.createConnection(options, callback); + } catch (error: any) { + log('error', `Error creating connection: ${error.message}`); + error.timeline = timeline; + throw error; + } + + // Attach event listeners to the socket + socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => { + if (err) { + log('error', `DNS lookup error for ${host}: ${err.message}`); + } else { + log('info', `DNS lookup: ${host} -> ${address}`); + } + }); + + socket?.on('connect', () => { + const address = socket.remoteAddress || host; + const remotePort = socket.remotePort || port; + + log('info', `Connected to ${host} (${address}) port ${remotePort}`); + }); + + socket?.on('error', (err: Error) => { + log('error', `Socket error: ${err.message}`); + }); + + return socket; + } + } as unknown as HttpAgentClass; +} + +export { createTimelineAgentClass, createTimelineHttpAgentClass, TimelineEntry, AgentOptions, HttpAgentOptions, CaCertificatesCount, AgentClass, HttpAgentClass, ProxyAgentClass, HttpProxyAgentClass };