mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
34 Commits
main
...
feat/ssl-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b74b76cc9a | ||
|
|
f070812845 | ||
|
|
adf5721ae0 | ||
|
|
bad1a02116 | ||
|
|
070c840e52 | ||
|
|
41f3519dcc | ||
|
|
c4c0576660 | ||
|
|
594fc30f9f | ||
|
|
8b08ba1ee9 | ||
|
|
3619448d55 | ||
|
|
b29bdc1e97 | ||
|
|
05bbb54df2 | ||
|
|
795fb08d1f | ||
|
|
0f05808886 | ||
|
|
592dd7d9e9 | ||
|
|
a5ff9cf144 | ||
|
|
93600b5da8 | ||
|
|
0f1febc1fe | ||
|
|
296612dcbc | ||
|
|
3e88cd6759 | ||
|
|
37d1b3c5f9 | ||
|
|
15c86f8e6b | ||
|
|
14c66bc42f | ||
|
|
f5a53319e0 | ||
|
|
61a260f71c | ||
|
|
c6f3007dbf | ||
|
|
8605810747 | ||
|
|
c2bad2e2c8 | ||
|
|
dbc1d11e23 | ||
|
|
9df4b04ae8 | ||
|
|
f51a7b2ded | ||
|
|
e2d3b4dbe8 | ||
|
|
28c4e24e2e | ||
|
|
9cbc58df70 |
@@ -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;
|
||||
122
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
122
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
@@ -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 (
|
||||
<StyledWrapper className="w-full">
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
|
||||
|
||||
<div className="flex items-center my-2">
|
||||
<input
|
||||
id="sslSession.enabled"
|
||||
type="checkbox"
|
||||
name="sslSession.enabled"
|
||||
checked={formik.values.sslSession.enabled}
|
||||
onChange={handleAgentCachingChange}
|
||||
className="mousetrap mr-0"
|
||||
/>
|
||||
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
|
||||
Enable SSL session caching
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-xs mt-1 ml-6 opacity-70">
|
||||
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
|
||||
request.
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cache;
|
||||
@@ -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 <Support />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,6 +98,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
<IconZoomQuestion size={16} strokeWidth={1.5} />
|
||||
Support
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
374
packages/bruno-requests/src/utils/agent-cache.spec.ts
Normal file
374
packages/bruno-requests/src/utils/agent-cache.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
393
packages/bruno-requests/src/utils/agent-cache.ts
Normal file
393
packages/bruno-requests/src/utils/agent-cache.ts
Normal file
@@ -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<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 };
|
||||
@@ -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<string, number> = {
|
||||
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<string, any>;
|
||||
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<any> {
|
||||
private constructorOpts: any;
|
||||
@@ -201,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
|
||||
}
|
||||
|
||||
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<any> | 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<AgentResult> => {
|
||||
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 };
|
||||
|
||||
309
packages/bruno-requests/src/utils/timeline-agent.ts
Normal file
309
packages/bruno-requests/src/utils/timeline-agent.ts
Normal file
@@ -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<T extends ProxyAgentClass | typeof https.Agent>(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<T extends HttpProxyAgentClass | typeof http.Agent>(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 };
|
||||
Reference in New Issue
Block a user