mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 01:41:29 +00:00
fix: honor OS-level PAC configuration in system proxy mode (#7766)
This commit is contained in:
@@ -18,7 +18,7 @@ runs:
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:e2e
|
||||
run: xvfb-run dbus-run-session -- npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
|
||||
3
.github/workflows/tests-linux.yml
vendored
3
.github/workflows/tests-linux.yml
vendored
@@ -59,7 +59,8 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
xvfb \
|
||||
gsettings-desktop-schemas dbus-x11
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e": "playwright test --project=default --project=system-pac",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:e2e:auth": "playwright test --project=auth",
|
||||
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
|
||||
|
||||
@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
|
||||
const SystemProxy = () => {
|
||||
const dispatch = useDispatch();
|
||||
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
|
||||
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
|
||||
const { source, http_proxy, https_proxy, no_proxy, pac_url } = systemProxyVariables || {};
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -85,6 +85,12 @@ const SystemProxy = () => {
|
||||
</label>
|
||||
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label">
|
||||
pac_url
|
||||
</label>
|
||||
<div className="system-proxy-value">{pac_url || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
|
||||
|
||||
@@ -421,8 +421,8 @@ const runSingleRequest = async function (
|
||||
} else if (!collectionProxyDisabled && collectionProxyInherit) {
|
||||
// Inherit from system proxy
|
||||
if (cachedSystemProxy) {
|
||||
const { http_proxy, https_proxy } = cachedSystemProxy;
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
const { http_proxy, https_proxy, pac_url } = cachedSystemProxy;
|
||||
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
}
|
||||
@@ -430,7 +430,7 @@ const runSingleRequest = async function (
|
||||
}
|
||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||
|
||||
setupProxyAgents({
|
||||
await setupProxyAgents({
|
||||
requestConfig: request,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
|
||||
@@ -139,7 +139,7 @@ function makeAxiosInstance({
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error.response) {
|
||||
const end = Date.now();
|
||||
const start = error.config.headers['request-start-time'];
|
||||
@@ -179,7 +179,7 @@ function makeAxiosInstance({
|
||||
|
||||
const requestConfig = createRedirectConfig(error, redirectUrl);
|
||||
|
||||
setupProxyAgents({
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
|
||||
@@ -2,10 +2,14 @@ const parseUrl = require('url').parse;
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const {
|
||||
getOrCreateHttpsAgent,
|
||||
getOrCreateHttpAgent,
|
||||
resolveAgentsFromPac,
|
||||
PatchedHttpsProxyAgent
|
||||
} = require('@usebruno/requests');
|
||||
const { interpolateString } = require('../runner/interpolate-string');
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
@@ -68,41 +72,7 @@ const shouldUseProxy = (url, proxyBypass) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
super(proxy, opts);
|
||||
this.constructorOpts = opts;
|
||||
}
|
||||
|
||||
async connect(req, opts) {
|
||||
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 setupProxyAgents({
|
||||
async function setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode = 'off',
|
||||
proxyConfig,
|
||||
@@ -163,26 +133,36 @@ function setupProxyAgents({
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
try {
|
||||
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
const { http_proxy, https_proxy, no_proxy, pac_url } = systemProxyConfig || {};
|
||||
|
||||
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
|
||||
if (pac_url) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
|
||||
const { httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, disableCache, hostname });
|
||||
if (httpAgent) requestConfig.httpAgent = httpAgent;
|
||||
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
|
||||
} catch (error) {}
|
||||
} else {
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system http_proxy');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system http_proxy');
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
@@ -148,7 +148,7 @@ const getCertsAndProxyConfig = async ({
|
||||
} else if (globalProxySource === 'inherit') {
|
||||
proxyMode = 'system';
|
||||
const systemProxyConfig = await getCachedSystemProxy();
|
||||
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
|
||||
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, pac_url: null, source: 'cache-miss' };
|
||||
} else {
|
||||
// source === 'manual'
|
||||
proxyConfig = globalProxyConfigData;
|
||||
|
||||
@@ -12,6 +12,7 @@ const loadSystemProxy = async () => {
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'error'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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 {
|
||||
getOrCreateHttpsAgent,
|
||||
getOrCreateHttpAgent,
|
||||
resolveAgentsFromPac,
|
||||
PatchedHttpsProxyAgent
|
||||
} = require('@usebruno/requests');
|
||||
const { preferencesUtil } = require('../store/preferences');
|
||||
const { getPacResolver } = require('@usebruno/requests');
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
ftp: 21,
|
||||
@@ -70,40 +73,6 @@ const shouldUseProxy = (url, proxyBypass) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
super(proxy, opts);
|
||||
this.constructorOpts = opts;
|
||||
}
|
||||
|
||||
async connect(req, opts) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode = 'off',
|
||||
@@ -184,40 +153,58 @@ async function setupProxyAgents({
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
const { http_proxy, https_proxy, no_proxy, pac_url } = proxyConfig || {};
|
||||
|
||||
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
|
||||
if (pac_url) {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving system PAC: ${pac_url}` });
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
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 });
|
||||
const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
|
||||
if (httpAgent) requestConfig.httpAgent = httpAgent;
|
||||
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
|
||||
if (directives) {
|
||||
if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` }); }
|
||||
} else {
|
||||
if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: 'System PAC resolved: DIRECT (no proxy)' }); }
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
|
||||
} catch (err) {
|
||||
if (timeline) { timeline.push({ timestamp: new Date(), type: 'error', message: `System PAC resolution failed: ${err.message}` }); }
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
if (timeline) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using system proxy: ${https_proxy}`
|
||||
});
|
||||
} else {
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
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 });
|
||||
}
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
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}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'pac') {
|
||||
@@ -225,26 +212,11 @@ async function setupProxyAgents({
|
||||
if (pacSource) {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
|
||||
try {
|
||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
|
||||
const directives = await resolver.resolve(requestConfig.url);
|
||||
if (directives && directives.length) {
|
||||
const first = directives[0];
|
||||
const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
|
||||
if (httpAgent) requestConfig.httpAgent = httpAgent;
|
||||
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
|
||||
if (directives) {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` });
|
||||
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
|
||||
const parts = first.split(/\s+/);
|
||||
const keyword = parts[0].toUpperCase();
|
||||
const hostPort = parts[1];
|
||||
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
|
||||
const proxyUri = `${scheme}://${hostPort}`;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
|
||||
} else if (/^SOCKS/i.test(first)) {
|
||||
const hostPort = first.split(/\s+/)[1];
|
||||
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
|
||||
const proxyUri = `${proto}://${hostPort}`;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: 'PAC resolved: DIRECT (no proxy)' });
|
||||
}
|
||||
|
||||
@@ -12,16 +12,51 @@ const setupMocks = ({ pacDirectives = ['PROXY p.example:8080'] } = {}) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// @usebruno/requests — agent factories + pac resolver
|
||||
jest.doMock('@usebruno/requests', () => ({
|
||||
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
||||
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
||||
getPacResolver: jest.fn(async () => ({
|
||||
// @usebruno/requests — agent factories + pac resolver + shared resolveAgentsFromPac
|
||||
jest.doMock('@usebruno/requests', () => {
|
||||
const getOrCreateHttpsAgent = jest.fn(() => ({ type: 'https-agent' }));
|
||||
const getOrCreateHttpAgent = jest.fn(() => ({ type: 'http-agent' }));
|
||||
const getPacResolver = jest.fn(async () => ({
|
||||
resolve: async () => pacDirectives,
|
||||
dispose: () => {}
|
||||
})),
|
||||
clearPacCache: jest.fn()
|
||||
}));
|
||||
}));
|
||||
// Inline mock of resolveAgentsFromPac that wires through the mocked factories
|
||||
// so existing assertions on getOrCreateHttp(s)Agent call args still hold.
|
||||
const resolveAgentsFromPac = jest.fn(async ({ pacSource, requestUrl, tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname }) => {
|
||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
|
||||
const directives = await resolver.resolve(requestUrl);
|
||||
if (!directives || !directives.length) return { directives: null };
|
||||
const first = directives[0];
|
||||
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
|
||||
const parts = first.split(/\s+/);
|
||||
const scheme = parts[0].toUpperCase() === 'HTTPS' ? 'https' : 'http';
|
||||
const proxyUri = `${scheme}://${parts[1]}`;
|
||||
return {
|
||||
directives,
|
||||
httpAgent: getOrCreateHttpAgent({ proxyUri, options: { keepAlive: true }, timeline, disableCache, hostname }),
|
||||
httpsAgent: getOrCreateHttpsAgent({ proxyUri, options: tlsOptions, timeline, disableCache, hostname })
|
||||
};
|
||||
}
|
||||
if (/^SOCKS/i.test(first)) {
|
||||
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
|
||||
const proxyUri = `${proto}://${first.split(/\s+/)[1]}`;
|
||||
return {
|
||||
directives,
|
||||
httpAgent: getOrCreateHttpAgent({ proxyUri, options: { keepAlive: true }, timeline, disableCache, hostname }),
|
||||
httpsAgent: getOrCreateHttpsAgent({ proxyUri, options: tlsOptions, timeline, disableCache, hostname })
|
||||
};
|
||||
}
|
||||
return { directives };
|
||||
});
|
||||
return {
|
||||
getOrCreateHttpsAgent,
|
||||
getOrCreateHttpAgent,
|
||||
getPacResolver,
|
||||
resolveAgentsFromPac,
|
||||
PatchedHttpsProxyAgent: class {},
|
||||
clearPacCache: jest.fn()
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
describe('proxy-util', () => {
|
||||
@@ -120,6 +155,8 @@ describe('proxy-util', () => {
|
||||
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
||||
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
||||
getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
|
||||
resolveAgentsFromPac: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
|
||||
PatchedHttpsProxyAgent: class {},
|
||||
clearPacCache: jest.fn()
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export { getCACertificates } from './utils/ca-cert';
|
||||
export { transformProxyConfig } from './utils/proxy-util';
|
||||
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 { getHttpHttpsAgents, resolveAgentsFromPac, PatchedHttpsProxyAgent } from './utils/http-https-agents';
|
||||
export { initializeShellEnv } from './utils/shell-env';
|
||||
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
|
||||
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
|
||||
|
||||
@@ -181,6 +181,7 @@ describe('SystemProxyResolver Integration', () => {
|
||||
http_proxy: 'http://env-proxy.usebruno.com:9090',
|
||||
https_proxy: 'https://system-proxy.usebruno.com:8443',
|
||||
no_proxy: 'localhost',
|
||||
pac_url: null,
|
||||
source: 'windows-system + environment'
|
||||
});
|
||||
});
|
||||
@@ -209,6 +210,7 @@ describe('SystemProxyResolver Integration', () => {
|
||||
http_proxy: 'http://system-proxy.usebruno.com:8080',
|
||||
https_proxy: 'https://system-proxy.usebruno.com:8443',
|
||||
no_proxy: 'localhost',
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -263,6 +265,7 @@ describe('SystemProxyResolver Integration', () => {
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function getSystemProxy(): Promise<ProxyConfiguration> {
|
||||
http_proxy: proxyEnvironmentVariables?.http_proxy || systemProxyEnvironmentVariables?.http_proxy,
|
||||
https_proxy: proxyEnvironmentVariables?.https_proxy || systemProxyEnvironmentVariables?.https_proxy,
|
||||
no_proxy: proxyEnvironmentVariables?.no_proxy || systemProxyEnvironmentVariables?.no_proxy,
|
||||
pac_url: systemProxyEnvironmentVariables?.pac_url || null,
|
||||
source: hasEnvironmentProxy ? `${systemProxyEnvironmentVariables?.source} + environment` : systemProxyEnvironmentVariables?.source
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface ProxyConfiguration {
|
||||
http_proxy?: string | null;
|
||||
https_proxy?: string | null;
|
||||
no_proxy?: string | null;
|
||||
pac_url?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -90,10 +90,36 @@ describe('LinuxProxyResolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-manual proxy mode', async () => {
|
||||
const modeOutput = '\'auto\'';
|
||||
it('should detect PAC URL when gsettings is in auto mode', async () => {
|
||||
mockExecFile
|
||||
.mockResolvedValueOnce({ stdout: '\'auto\'', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '\'http://wpad.usebruno.com/proxy.pac\'', stderr: '' });
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: modeOutput, stderr: '' });
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: 'http://wpad.usebruno.com/proxy.pac',
|
||||
source: 'linux-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall through when gsettings auto mode has an empty autoconfig-url', async () => {
|
||||
mockExecFile
|
||||
.mockResolvedValueOnce({ stdout: '\'auto\'', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '\'\'', stderr: '' });
|
||||
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed');
|
||||
});
|
||||
|
||||
it('should fall through when gsettings mode is none', async () => {
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: '\'none\'', stderr: '' });
|
||||
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed');
|
||||
});
|
||||
|
||||
@@ -49,6 +49,23 @@ export class LinuxProxyResolver implements ProxyResolver {
|
||||
private async getGSettingsProxy(execOpts: ExecFileOptions): Promise<ProxyConfiguration | null> {
|
||||
try {
|
||||
const mode = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'mode'], execOpts);
|
||||
|
||||
// Handle PAC (auto) mode
|
||||
if (mode === '\'auto\'') {
|
||||
const autoConfigUrl = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'autoconfig-url'], execOpts);
|
||||
const cleanUrl = (autoConfigUrl || '').replace(/'/g, '').trim();
|
||||
if (cleanUrl) {
|
||||
return {
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: cleanUrl,
|
||||
source: 'linux-system'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode !== '\'manual\'') {
|
||||
return null;
|
||||
}
|
||||
@@ -93,8 +110,22 @@ export class LinuxProxyResolver implements ProxyResolver {
|
||||
// 3 = Automatic proxy detection
|
||||
// 4 = Use system proxy configuration (environment variables)
|
||||
|
||||
if (proxyType === '2') {
|
||||
const pacUrl = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'Proxy Config Script'], execOpts);
|
||||
const cleanPacUrl = (pacUrl || '').trim();
|
||||
if (cleanPacUrl) {
|
||||
return {
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: cleanPacUrl,
|
||||
source: 'linux-system'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (proxyType !== '1') {
|
||||
// Only handle manual proxy configuration for now
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
||||
no_proxy: 'localhost,127.0.0.1,<local>',
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -65,6 +66,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -102,6 +104,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -123,6 +126,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: null,
|
||||
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -148,6 +152,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: 'http://proxy.usebruno.com:8080',
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -171,6 +176,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: 'http://proxy.usebruno.com:8080',
|
||||
no_proxy: '<local>',
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
@@ -200,10 +206,72 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: 'http://proxy.usebruno.com:8080',
|
||||
no_proxy: 'localhost,127.0.0.1,*.local,192.168.1.0/24,<local>',
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract PAC URL when ProxyAutoConfigEnable is 1', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
HTTPEnable : 0
|
||||
HTTPSEnable : 0
|
||||
ProxyAutoConfigEnable : 1
|
||||
ProxyAutoConfigURLString : http://wpad.usebruno.com/proxy.pac
|
||||
}`;
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
|
||||
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: 'http://wpad.usebruno.com/proxy.pac',
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return PAC URL alongside manual HTTP/HTTPS proxies when both are configured', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 8080
|
||||
HTTPProxy : proxy.usebruno.com
|
||||
HTTPSEnable : 1
|
||||
HTTPSPort : 8443
|
||||
HTTPSProxy : secure-proxy.usebruno.com
|
||||
ProxyAutoConfigEnable : 1
|
||||
ProxyAutoConfigURLString : file:///etc/proxy.pac
|
||||
}`;
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
|
||||
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
||||
no_proxy: null,
|
||||
pac_url: 'file:///etc/proxy.pac',
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return PAC URL when ProxyAutoConfigEnable is 0', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
HTTPEnable : 0
|
||||
HTTPSEnable : 0
|
||||
ProxyAutoConfigEnable : 0
|
||||
ProxyAutoConfigURLString : http://wpad.usebruno.com/proxy.pac
|
||||
}`;
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
|
||||
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result.pac_url).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle malformed scutil output gracefully', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
@@ -222,6 +290,7 @@ describe('MacOSProxyResolver', () => {
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: null,
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,12 @@ export class MacOSProxyResolver implements ProxyResolver {
|
||||
let http_proxy: string | null = null;
|
||||
let https_proxy: string | null = null;
|
||||
let no_proxy: string | null = null;
|
||||
let pac_url: string | null = null;
|
||||
|
||||
// Check PAC (Proxy Auto-Configuration)
|
||||
if (config.ProxyAutoConfigEnable === 1 && config.ProxyAutoConfigURLString) {
|
||||
pac_url = config.ProxyAutoConfigURLString;
|
||||
}
|
||||
|
||||
// Check HTTP proxy
|
||||
if (config.HTTPEnable === 1 && config.HTTPProxy) {
|
||||
@@ -109,6 +115,7 @@ export class MacOSProxyResolver implements ProxyResolver {
|
||||
http_proxy,
|
||||
https_proxy,
|
||||
no_proxy: normalizeNoProxy(no_proxy),
|
||||
pac_url,
|
||||
source: 'macos-system'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +83,71 @@ Current WinHTTP proxy settings:
|
||||
});
|
||||
});
|
||||
|
||||
describe('AutoConfigURL (PAC) Detection', () => {
|
||||
it('should return pac_url when only AutoConfigURL is set', async () => {
|
||||
const regOutput = `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x0
|
||||
AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
|
||||
`;
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
|
||||
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: 'http://wpad.usebruno.com/proxy.pac',
|
||||
source: 'windows-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return both manual proxy and pac_url when both are configured', async () => {
|
||||
const regOutput = `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x1
|
||||
ProxyServer REG_SZ proxy.usebruno.com:8080
|
||||
ProxyOverride REG_SZ localhost;127.0.0.1
|
||||
AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
|
||||
`;
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
|
||||
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||
https_proxy: 'http://proxy.usebruno.com:8080',
|
||||
no_proxy: 'localhost,127.0.0.1',
|
||||
pac_url: 'http://wpad.usebruno.com/proxy.pac',
|
||||
source: 'windows-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return pac_url with null proxies when ProxyEnable=0 but AutoConfigURL is set', async () => {
|
||||
const regOutput = `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x0
|
||||
ProxyServer REG_SZ proxy.usebruno.com:8080
|
||||
AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
|
||||
`;
|
||||
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
|
||||
|
||||
const result = await detector.detect();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: null,
|
||||
https_proxy: null,
|
||||
no_proxy: null,
|
||||
pac_url: 'http://wpad.usebruno.com/proxy.pac',
|
||||
source: 'windows-system'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WinHTTP Detection', () => {
|
||||
it('should handle direct access configuration', async () => {
|
||||
mockExecFile
|
||||
|
||||
@@ -46,6 +46,7 @@ export class WindowsProxyResolver implements ProxyResolver {
|
||||
let proxyEnabled = false;
|
||||
let proxyServer: string | null = null;
|
||||
let proxyOverride: string | null = null;
|
||||
let autoConfigURL: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -68,6 +69,19 @@ export class WindowsProxyResolver implements ProxyResolver {
|
||||
const match = trimmedLine.match(/ProxyOverride\s+REG_SZ\s+(.+)/);
|
||||
if (match) proxyOverride = match[1].trim();
|
||||
}
|
||||
|
||||
if (trimmedLine.includes('AutoConfigURL') && trimmedLine.includes('REG_SZ')) {
|
||||
const match = trimmedLine.match(/AutoConfigURL\s+REG_SZ\s+(.+)/);
|
||||
if (match) autoConfigURL = match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// PAC URL takes precedence — return it even without a manual proxy
|
||||
if (autoConfigURL) {
|
||||
const config = proxyEnabled && proxyServer
|
||||
? this.parseProxyString(proxyServer, proxyOverride)
|
||||
: { http_proxy: null, https_proxy: null, no_proxy: null, source: 'windows-system' };
|
||||
return { ...config, pac_url: autoConfigURL };
|
||||
}
|
||||
|
||||
if (proxyEnabled && proxyServer) {
|
||||
|
||||
@@ -49,6 +49,7 @@ type SystemProxyConfig = {
|
||||
http_proxy?: string;
|
||||
https_proxy?: string;
|
||||
no_proxy?: string;
|
||||
pac_url?: string | null;
|
||||
};
|
||||
|
||||
type ClientCertificate = {
|
||||
@@ -215,7 +216,7 @@ const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthori
|
||||
* `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> {
|
||||
export class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
|
||||
private constructorOpts: any;
|
||||
|
||||
constructor(proxy: string, opts: any) {
|
||||
@@ -349,8 +350,8 @@ const getCertsAndProxyConfig = ({
|
||||
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
|
||||
proxyMode = 'pac';
|
||||
} else if (globalProxySource === 'inherit') {
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
} else {
|
||||
@@ -362,8 +363,8 @@ const getCertsAndProxyConfig = ({
|
||||
// else: app-level proxy is disabled, proxyMode stays 'off'
|
||||
} else {
|
||||
// No app-level proxy config (e.g. CLI), fall through to system proxy
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
}
|
||||
@@ -382,6 +383,79 @@ function extractHostname(url: string | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
type ResolveAgentsFromPacParams = {
|
||||
pacSource: string;
|
||||
requestUrl: string;
|
||||
tlsOptions: TlsOptions;
|
||||
httpsAgentRequestFields?: HttpsAgentRequestFields;
|
||||
requestProtocol?: 'http' | 'https' | 'both';
|
||||
timeline?: TimelineEntry[] | null;
|
||||
disableCache: boolean;
|
||||
hostname: string | null;
|
||||
};
|
||||
|
||||
type ResolveAgentsFromPacResult = {
|
||||
directives: string[] | null;
|
||||
httpAgent?: HttpAgent;
|
||||
httpsAgent?: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a PAC URL and creates proxy agents from the first directive.
|
||||
* `requestProtocol` controls which agent(s) get created:
|
||||
* - 'http' or 'https': create only the matching agent (optimization for known request type)
|
||||
* - 'both' (default): create both, caller picks
|
||||
*/
|
||||
export async function resolveAgentsFromPac({
|
||||
pacSource,
|
||||
requestUrl,
|
||||
tlsOptions,
|
||||
httpsAgentRequestFields,
|
||||
requestProtocol = 'both',
|
||||
timeline,
|
||||
disableCache,
|
||||
hostname
|
||||
}: ResolveAgentsFromPacParams): Promise<ResolveAgentsFromPacResult> {
|
||||
const pacResolverFields = httpsAgentRequestFields || {
|
||||
ca: tlsOptions.ca,
|
||||
rejectUnauthorized: tlsOptions.rejectUnauthorized,
|
||||
minVersion: tlsOptions.minVersion
|
||||
};
|
||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: pacResolverFields });
|
||||
const directives = await resolver.resolve(requestUrl);
|
||||
|
||||
if (!directives || !directives.length) {
|
||||
return { directives: null };
|
||||
}
|
||||
|
||||
const wantHttp = requestProtocol === 'http' || requestProtocol === 'both';
|
||||
const wantHttps = requestProtocol === 'https' || requestProtocol === 'both';
|
||||
const first = directives[0];
|
||||
|
||||
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
|
||||
const parts = first.split(/\s+/);
|
||||
const keyword = parts[0].toUpperCase();
|
||||
const hostPort = parts[1];
|
||||
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
|
||||
const proxyUri = `${scheme}://${hostPort}`;
|
||||
const result: ResolveAgentsFromPacResult = { directives };
|
||||
if (wantHttp) result.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
if (wantHttps) result.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
return result;
|
||||
}
|
||||
if (/^SOCKS/i.test(first)) {
|
||||
const hostPort = first.split(/\s+/)[1];
|
||||
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
|
||||
const proxyUri = `${proto}://${hostPort}`;
|
||||
const result: ResolveAgentsFromPacResult = { directives };
|
||||
if (wantHttp) result.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
if (wantHttps) result.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
return result;
|
||||
}
|
||||
|
||||
return { directives };
|
||||
}
|
||||
|
||||
async function createAgents({
|
||||
requestUrl,
|
||||
proxyMode,
|
||||
@@ -459,32 +533,9 @@ async function createAgents({
|
||||
const pacSource = get(proxyConfig, 'pac.source');
|
||||
if (pacSource && requestUrl) {
|
||||
try {
|
||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: { ca: tlsOptions.ca, rejectUnauthorized: tlsOptions.rejectUnauthorized, minVersion: tlsOptions.minVersion } });
|
||||
const directives = await resolver.resolve(requestUrl);
|
||||
if (directives && directives.length) {
|
||||
const first = directives[0];
|
||||
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
|
||||
const parts = first.split(/\s+/);
|
||||
const keyword = parts[0].toUpperCase();
|
||||
const hostPort = parts[1];
|
||||
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
|
||||
const proxyUri = `${scheme}://${hostPort}`;
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
} else if (/^SOCKS/i.test(first)) {
|
||||
const hostPort = first.split(/\s+/)[1];
|
||||
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
|
||||
const proxyUri = `${proto}://${hostPort}`;
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await resolveAgentsFromPac({ pacSource, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
|
||||
if (result.httpAgent) httpAgent = result.httpAgent;
|
||||
if (result.httpsAgent) httpsAgent = result.httpsAgent;
|
||||
} catch {
|
||||
// PAC resolution failed — fall through to direct connection
|
||||
}
|
||||
@@ -493,25 +544,37 @@ async function createAgents({
|
||||
const http_proxy = get(systemProxyConfig, 'http_proxy');
|
||||
const https_proxy = get(systemProxyConfig, 'https_proxy');
|
||||
const no_proxy = get(systemProxyConfig, 'no_proxy');
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
const pac_url = get(systemProxyConfig, 'pac_url');
|
||||
|
||||
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
|
||||
if (pac_url && requestUrl) {
|
||||
try {
|
||||
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');
|
||||
const result = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
|
||||
if (result.httpAgent) httpAgent = result.httpAgent;
|
||||
if (result.httpsAgent) httpsAgent = result.httpsAgent;
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
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 && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
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');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,4 +635,4 @@ const getHttpHttpsAgents = async ({
|
||||
|
||||
export { getHttpHttpsAgents };
|
||||
|
||||
export type { GetHttpHttpsAgentsParams };
|
||||
export type { GetHttpHttpsAgentsParams, ResolveAgentsFromPacParams, ResolveAgentsFromPacResult };
|
||||
|
||||
@@ -3,20 +3,28 @@
|
||||
"name": "bruno-testbench",
|
||||
"type": "collection",
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"protocol": "http",
|
||||
"hostname": "{{proxyHostname}}",
|
||||
"port": 4000,
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"username": "anoop",
|
||||
"password": "password"
|
||||
},
|
||||
"bypassProxy": ""
|
||||
"inherit": true,
|
||||
"config": {
|
||||
"protocol": "http",
|
||||
"hostname": "{{proxyHostname}}",
|
||||
"port": 4000,
|
||||
"auth": {
|
||||
"username": "anoop",
|
||||
"password": "password",
|
||||
"disabled": true
|
||||
},
|
||||
"bypassProxy": ""
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"],
|
||||
"additionalContextRoots": ["../additional-context-root-lib"]
|
||||
"moduleWhitelist": [
|
||||
"crypto",
|
||||
"buffer",
|
||||
"form-data"
|
||||
],
|
||||
"additionalContextRoots": [
|
||||
"../additional-context-root-lib"
|
||||
]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -24,7 +24,8 @@ export default defineConfig({
|
||||
testIgnore: [
|
||||
'ssl/**', // custom CA certificate tests require separate server setup and certificate generation
|
||||
'auth/**', // auth tests have their own project
|
||||
'benchmarks/**'
|
||||
'benchmarks/**',
|
||||
'proxy/system-pac/**' // shares ports with proxy/pac — runs in its own project after default
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -34,6 +35,12 @@ export default defineConfig({
|
||||
{
|
||||
name: 'ssl',
|
||||
testDir: './tests/ssl'
|
||||
},
|
||||
{
|
||||
// system-pac and pac specs share the same PAC/proxy/target ports.
|
||||
name: 'system-pac',
|
||||
testDir: './tests/proxy/system-pac',
|
||||
dependencies: ['default']
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
6
tests/proxy/system-pac/fixtures/collection/bruno.json
Normal file
6
tests/proxy/system-pac/fixtures/collection/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "system-pac-proxy-test",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
21
tests/proxy/system-pac/fixtures/collection/direct.bru
Normal file
21
tests/proxy/system-pac/fixtures/collection/direct.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: direct
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:19000/direct
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("request bypassed proxy (system PAC returned DIRECT)", function() {
|
||||
expect(res.headers['x-proxied']).to.be.undefined;
|
||||
});
|
||||
}
|
||||
21
tests/proxy/system-pac/fixtures/collection/proxied.bru
Normal file
21
tests/proxy/system-pac/fixtures/collection/proxied.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: proxied
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:19000/proxied
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("request was routed through system PAC proxy", function() {
|
||||
expect(res.headers['x-proxied']).to.equal('test-proxy');
|
||||
});
|
||||
}
|
||||
14
tests/proxy/system-pac/init-user-data/preferences.json
Normal file
14
tests/proxy/system-pac/init-user-data/preferences.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": ["{{projectRoot}}/tests/proxy/system-pac/fixtures/collection"],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
},
|
||||
"proxy": {
|
||||
"source": "inherit",
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
tests/proxy/system-pac/system-pac-proxy.spec.ts
Normal file
103
tests/proxy/system-pac/system-pac-proxy.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { test } from '../../../playwright';
|
||||
import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page';
|
||||
import { startServers, stopServers, PAC_PORT, type TestServers } from '../pac/server';
|
||||
|
||||
// GNOME's system-wide proxy schema — provided by gsettings-desktop-schemas.
|
||||
// Writes need an active dbus session (see CI workflow's dbus-run-session wrapper).
|
||||
const GNOME_PROXY_SCHEMA = 'org.gnome.system.proxy';
|
||||
|
||||
function enableSystemPac(pacUrl: string) {
|
||||
// Set URL first, then flip mode — otherwise auto mode briefly has no URL
|
||||
execFileSync('gsettings', ['set', GNOME_PROXY_SCHEMA, 'autoconfig-url', pacUrl]);
|
||||
execFileSync('gsettings', ['set', GNOME_PROXY_SCHEMA, 'mode', 'auto']);
|
||||
}
|
||||
|
||||
function disableSystemPac() {
|
||||
execFileSync('gsettings', ['reset', GNOME_PROXY_SCHEMA, 'mode']);
|
||||
execFileSync('gsettings', ['reset', GNOME_PROXY_SCHEMA, 'autoconfig-url']);
|
||||
}
|
||||
|
||||
// Detects schema availability so we can skip cleanly on minimal images
|
||||
// (e.g. containers without gsettings-desktop-schemas installed).
|
||||
function gnomeProxySchemaAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('gsettings', ['get', GNOME_PROXY_SCHEMA, 'mode'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('System Proxy with PAC', () => {
|
||||
test.skip(
|
||||
process.platform !== 'linux',
|
||||
'Linux-only: relies on gsettings to set OS-level PAC'
|
||||
);
|
||||
|
||||
test.skip(
|
||||
process.platform === 'linux' && !gnomeProxySchemaAvailable(),
|
||||
'Linux: skipping because the org.gnome.system.proxy GSettings schema is not available on this runner'
|
||||
);
|
||||
|
||||
let servers: TestServers;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
servers = await startServers();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Revert OS proxy settings even if a test failed, so the runner is left clean.
|
||||
try {
|
||||
disableSystemPac();
|
||||
} finally {
|
||||
if (servers) {
|
||||
await stopServers(servers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Covers the common corporate setup: PAC hosted at an HTTP URL (e.g. WPAD).
|
||||
test('resolves OS-level PAC URL in system proxy mode (HTTP PAC)', async ({ launchElectronApp }) => {
|
||||
const pacUrl = `http://localhost:${PAC_PORT}/test.pac`;
|
||||
enableSystemPac(pacUrl);
|
||||
|
||||
const initUserDataPath = path.join(__dirname, 'init-user-data');
|
||||
const app = await launchElectronApp({ initUserDataPath });
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await setSandboxMode(page, 'system-pac-proxy-test', 'developer');
|
||||
await runCollection(page, 'system-pac-proxy-test');
|
||||
await validateRunnerResults(page, {
|
||||
totalRequests: 2,
|
||||
passed: 2,
|
||||
failed: 0,
|
||||
skipped: 0
|
||||
});
|
||||
});
|
||||
|
||||
// Covers the local-file PAC case (user-selected .pac file on disk).
|
||||
test('resolves OS-level PAC URL in system proxy mode (file:// PAC)', async ({ launchElectronApp }) => {
|
||||
const pacUrl = pathToFileURL(path.join(__dirname, '..', 'pac', 'fixtures', 'pac-files', 'test.pac')).href;
|
||||
enableSystemPac(pacUrl);
|
||||
|
||||
const initUserDataPath = path.join(__dirname, 'init-user-data');
|
||||
const app = await launchElectronApp({ initUserDataPath });
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await setSandboxMode(page, 'system-pac-proxy-test', 'developer');
|
||||
await runCollection(page, 'system-pac-proxy-test');
|
||||
await validateRunnerResults(page, {
|
||||
totalRequests: 2,
|
||||
passed: 2,
|
||||
failed: 0,
|
||||
skipped: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user