mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +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)
|
- name: Run Playwright Tests (Ubuntu)
|
||||||
if: inputs.os == 'ubuntu'
|
if: inputs.os == 'ubuntu'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: xvfb-run npm run test:e2e
|
run: xvfb-run dbus-run-session -- npm run test:e2e
|
||||||
|
|
||||||
- name: Run Playwright Tests
|
- name: Run Playwright Tests
|
||||||
if: inputs.os != 'ubuntu'
|
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 update
|
||||||
sudo apt-get --no-install-recommends install -y \
|
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 \
|
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
|
- name: Setup Node Dependencies
|
||||||
uses: ./.github/actions/common/setup-node-deps
|
uses: ./.github/actions/common/setup-node-deps
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||||
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
||||||
"test:codegen": "node playwright/codegen.ts",
|
"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:ssl": "playwright test --project=ssl",
|
||||||
"test:e2e:auth": "playwright test --project=auth",
|
"test:e2e:auth": "playwright test --project=auth",
|
||||||
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
|
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
|
|||||||
const SystemProxy = () => {
|
const SystemProxy = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
|
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 [isFetching, setIsFetching] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
@@ -85,6 +85,12 @@ const SystemProxy = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
||||||
</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>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
|
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) {
|
} else if (!collectionProxyDisabled && collectionProxyInherit) {
|
||||||
// Inherit from system proxy
|
// Inherit from system proxy
|
||||||
if (cachedSystemProxy) {
|
if (cachedSystemProxy) {
|
||||||
const { http_proxy, https_proxy } = cachedSystemProxy;
|
const { http_proxy, https_proxy, pac_url } = cachedSystemProxy;
|
||||||
if (http_proxy?.length || https_proxy?.length) {
|
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
|
||||||
proxyMode = 'system';
|
proxyMode = 'system';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,7 +430,7 @@ const runSingleRequest = async function (
|
|||||||
}
|
}
|
||||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||||
|
|
||||||
setupProxyAgents({
|
await setupProxyAgents({
|
||||||
requestConfig: request,
|
requestConfig: request,
|
||||||
proxyMode,
|
proxyMode,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function makeAxiosInstance({
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const end = Date.now();
|
const end = Date.now();
|
||||||
const start = error.config.headers['request-start-time'];
|
const start = error.config.headers['request-start-time'];
|
||||||
@@ -179,7 +179,7 @@ function makeAxiosInstance({
|
|||||||
|
|
||||||
const requestConfig = createRedirectConfig(error, redirectUrl);
|
const requestConfig = createRedirectConfig(error, redirectUrl);
|
||||||
|
|
||||||
setupProxyAgents({
|
await setupProxyAgents({
|
||||||
requestConfig,
|
requestConfig,
|
||||||
proxyMode,
|
proxyMode,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ const parseUrl = require('url').parse;
|
|||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const https = require('node:https');
|
const https = require('node:https');
|
||||||
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
|
||||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||||
const { SocksProxyAgent } = require('socks-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 { interpolateString } = require('../runner/interpolate-string');
|
||||||
|
|
||||||
const DEFAULT_PORTS = {
|
const DEFAULT_PORTS = {
|
||||||
@@ -68,41 +72,7 @@ const shouldUseProxy = (url, proxyBypass) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
async function setupProxyAgents({
|
||||||
* 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({
|
|
||||||
requestConfig,
|
requestConfig,
|
||||||
proxyMode = 'off',
|
proxyMode = 'off',
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
@@ -163,26 +133,36 @@ function setupProxyAgents({
|
|||||||
}
|
}
|
||||||
} else if (proxyMode === 'system') {
|
} else if (proxyMode === 'system') {
|
||||||
try {
|
try {
|
||||||
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
|
const { http_proxy, https_proxy, no_proxy, pac_url } = systemProxyConfig || {};
|
||||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
|
||||||
if (shouldUseSystemProxy) {
|
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
|
||||||
|
if (pac_url) {
|
||||||
try {
|
try {
|
||||||
if (http_proxy?.length && !isHttpsRequest) {
|
const { httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, disableCache, hostname });
|
||||||
const parsedHttpProxy = new URL(http_proxy);
|
if (httpAgent) requestConfig.httpAgent = httpAgent;
|
||||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
|
||||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
} catch (error) {}
|
||||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
|
} 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) {
|
try {
|
||||||
throw new Error('Invalid system http_proxy');
|
if (https_proxy?.length && isHttpsRequest) {
|
||||||
}
|
new URL(https_proxy);
|
||||||
try {
|
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
||||||
if (https_proxy?.length && isHttpsRequest) {
|
}
|
||||||
new URL(https_proxy);
|
} catch (error) {
|
||||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
throw new Error('Invalid system https_proxy');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Invalid system https_proxy');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const getCertsAndProxyConfig = async ({
|
|||||||
} else if (globalProxySource === 'inherit') {
|
} else if (globalProxySource === 'inherit') {
|
||||||
proxyMode = 'system';
|
proxyMode = 'system';
|
||||||
const systemProxyConfig = await getCachedSystemProxy();
|
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 {
|
} else {
|
||||||
// source === 'manual'
|
// source === 'manual'
|
||||||
proxyConfig = globalProxyConfigData;
|
proxyConfig = globalProxyConfigData;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const loadSystemProxy = async () => {
|
|||||||
http_proxy: null,
|
http_proxy: null,
|
||||||
https_proxy: null,
|
https_proxy: null,
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'error'
|
source: 'error'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
const parseUrl = require('url').parse;
|
const parseUrl = require('url').parse;
|
||||||
const https = require('node:https');
|
const https = require('node:https');
|
||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
|
||||||
const { interpolateString } = require('../ipc/network/interpolate-string');
|
const { interpolateString } = require('../ipc/network/interpolate-string');
|
||||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||||
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
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 { preferencesUtil } = require('../store/preferences');
|
||||||
const { getPacResolver } = require('@usebruno/requests');
|
|
||||||
|
|
||||||
const DEFAULT_PORTS = {
|
const DEFAULT_PORTS = {
|
||||||
ftp: 21,
|
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({
|
async function setupProxyAgents({
|
||||||
requestConfig,
|
requestConfig,
|
||||||
proxyMode = 'off',
|
proxyMode = 'off',
|
||||||
@@ -184,40 +153,58 @@ async function setupProxyAgents({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (proxyMode === 'system') {
|
} else if (proxyMode === 'system') {
|
||||||
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
|
const { http_proxy, https_proxy, no_proxy, pac_url } = proxyConfig || {};
|
||||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
|
||||||
if (shouldUseSystemProxy) {
|
// 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 {
|
try {
|
||||||
if (http_proxy?.length && !isHttpsRequest) {
|
const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
|
||||||
const parsedHttpProxy = new URL(http_proxy);
|
if (httpAgent) requestConfig.httpAgent = httpAgent;
|
||||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
|
||||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
|
if (directives) {
|
||||||
if (timeline) {
|
if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` }); }
|
||||||
timeline.push({
|
} else {
|
||||||
timestamp: new Date(),
|
if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: 'System PAC resolved: DIRECT (no proxy)' }); }
|
||||||
type: 'info',
|
|
||||||
message: `Using system proxy: ${http_proxy}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
|
if (timeline) { timeline.push({ timestamp: new Date(), type: 'error', message: `System PAC resolution failed: ${err.message}` }); }
|
||||||
}
|
}
|
||||||
try {
|
} else {
|
||||||
if (https_proxy?.length && isHttpsRequest) {
|
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||||
new URL(https_proxy);
|
if (shouldUseSystemProxy) {
|
||||||
if (timeline) {
|
try {
|
||||||
timeline.push({
|
if (http_proxy?.length && !isHttpsRequest) {
|
||||||
timestamp: new Date(),
|
const parsedHttpProxy = new URL(http_proxy);
|
||||||
type: 'info',
|
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||||
message: `Using system proxy: ${https_proxy}`
|
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') {
|
} else if (proxyMode === 'pac') {
|
||||||
@@ -225,26 +212,11 @@ async function setupProxyAgents({
|
|||||||
if (pacSource) {
|
if (pacSource) {
|
||||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
|
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
|
||||||
try {
|
try {
|
||||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
|
const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
|
||||||
const directives = await resolver.resolve(requestConfig.url);
|
if (httpAgent) requestConfig.httpAgent = httpAgent;
|
||||||
if (directives && directives.length) {
|
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
|
||||||
const first = directives[0];
|
if (directives) {
|
||||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` });
|
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 {
|
} else {
|
||||||
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: 'PAC resolved: DIRECT (no proxy)' });
|
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
|
// @usebruno/requests — agent factories + pac resolver + shared resolveAgentsFromPac
|
||||||
jest.doMock('@usebruno/requests', () => ({
|
jest.doMock('@usebruno/requests', () => {
|
||||||
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
const getOrCreateHttpsAgent = jest.fn(() => ({ type: 'https-agent' }));
|
||||||
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
const getOrCreateHttpAgent = jest.fn(() => ({ type: 'http-agent' }));
|
||||||
getPacResolver: jest.fn(async () => ({
|
const getPacResolver = jest.fn(async () => ({
|
||||||
resolve: async () => pacDirectives,
|
resolve: async () => pacDirectives,
|
||||||
dispose: () => {}
|
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', () => {
|
describe('proxy-util', () => {
|
||||||
@@ -120,6 +155,8 @@ describe('proxy-util', () => {
|
|||||||
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
|
||||||
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
|
||||||
getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
|
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()
|
clearPacCache: jest.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export { getCACertificates } from './utils/ca-cert';
|
|||||||
export { transformProxyConfig } from './utils/proxy-util';
|
export { transformProxyConfig } from './utils/proxy-util';
|
||||||
export { default as createVaultClient, VaultError } from './utils/node-vault';
|
export { default as createVaultClient, VaultError } from './utils/node-vault';
|
||||||
export type { VaultClient, VaultConfig, VaultRequestOptions } 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 { initializeShellEnv } from './utils/shell-env';
|
||||||
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
|
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
|
||||||
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
|
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ describe('SystemProxyResolver Integration', () => {
|
|||||||
http_proxy: 'http://env-proxy.usebruno.com:9090',
|
http_proxy: 'http://env-proxy.usebruno.com:9090',
|
||||||
https_proxy: 'https://system-proxy.usebruno.com:8443',
|
https_proxy: 'https://system-proxy.usebruno.com:8443',
|
||||||
no_proxy: 'localhost',
|
no_proxy: 'localhost',
|
||||||
|
pac_url: null,
|
||||||
source: 'windows-system + environment'
|
source: 'windows-system + environment'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -209,6 +210,7 @@ describe('SystemProxyResolver Integration', () => {
|
|||||||
http_proxy: 'http://system-proxy.usebruno.com:8080',
|
http_proxy: 'http://system-proxy.usebruno.com:8080',
|
||||||
https_proxy: 'https://system-proxy.usebruno.com:8443',
|
https_proxy: 'https://system-proxy.usebruno.com:8443',
|
||||||
no_proxy: 'localhost',
|
no_proxy: 'localhost',
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -263,6 +265,7 @@ describe('SystemProxyResolver Integration', () => {
|
|||||||
http_proxy: null,
|
http_proxy: null,
|
||||||
https_proxy: null,
|
https_proxy: null,
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export async function getSystemProxy(): Promise<ProxyConfiguration> {
|
|||||||
http_proxy: proxyEnvironmentVariables?.http_proxy || systemProxyEnvironmentVariables?.http_proxy,
|
http_proxy: proxyEnvironmentVariables?.http_proxy || systemProxyEnvironmentVariables?.http_proxy,
|
||||||
https_proxy: proxyEnvironmentVariables?.https_proxy || systemProxyEnvironmentVariables?.https_proxy,
|
https_proxy: proxyEnvironmentVariables?.https_proxy || systemProxyEnvironmentVariables?.https_proxy,
|
||||||
no_proxy: proxyEnvironmentVariables?.no_proxy || systemProxyEnvironmentVariables?.no_proxy,
|
no_proxy: proxyEnvironmentVariables?.no_proxy || systemProxyEnvironmentVariables?.no_proxy,
|
||||||
|
pac_url: systemProxyEnvironmentVariables?.pac_url || null,
|
||||||
source: hasEnvironmentProxy ? `${systemProxyEnvironmentVariables?.source} + environment` : systemProxyEnvironmentVariables?.source
|
source: hasEnvironmentProxy ? `${systemProxyEnvironmentVariables?.source} + environment` : systemProxyEnvironmentVariables?.source
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface ProxyConfiguration {
|
|||||||
http_proxy?: string | null;
|
http_proxy?: string | null;
|
||||||
https_proxy?: string | null;
|
https_proxy?: string | null;
|
||||||
no_proxy?: string | null;
|
no_proxy?: string | null;
|
||||||
|
pac_url?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,36 @@ describe('LinuxProxyResolver', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle non-manual proxy mode', async () => {
|
it('should detect PAC URL when gsettings is in auto mode', async () => {
|
||||||
const modeOutput = '\'auto\'';
|
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');
|
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> {
|
private async getGSettingsProxy(execOpts: ExecFileOptions): Promise<ProxyConfiguration | null> {
|
||||||
try {
|
try {
|
||||||
const mode = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'mode'], execOpts);
|
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\'') {
|
if (mode !== '\'manual\'') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -93,8 +110,22 @@ export class LinuxProxyResolver implements ProxyResolver {
|
|||||||
// 3 = Automatic proxy detection
|
// 3 = Automatic proxy detection
|
||||||
// 4 = Use system proxy configuration (environment variables)
|
// 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') {
|
if (proxyType !== '1') {
|
||||||
// Only handle manual proxy configuration for now
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
||||||
no_proxy: 'localhost,127.0.0.1,<local>',
|
no_proxy: 'localhost,127.0.0.1,<local>',
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -65,6 +66,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: null,
|
http_proxy: null,
|
||||||
https_proxy: null,
|
https_proxy: null,
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -102,6 +104,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
https_proxy: null,
|
https_proxy: null,
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -123,6 +126,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: null,
|
http_proxy: null,
|
||||||
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
https_proxy: 'http://secure-proxy.usebruno.com:8443',
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -148,6 +152,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
https_proxy: 'http://proxy.usebruno.com:8080',
|
https_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -171,6 +176,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
https_proxy: 'http://proxy.usebruno.com:8080',
|
https_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
no_proxy: '<local>',
|
no_proxy: '<local>',
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -200,10 +206,72 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
https_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>',
|
no_proxy: 'localhost,127.0.0.1,*.local,192.168.1.0/24,<local>',
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
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 () => {
|
it('should handle malformed scutil output gracefully', async () => {
|
||||||
const scutilOutput = `<dictionary> {
|
const scutilOutput = `<dictionary> {
|
||||||
HTTPEnable : 1
|
HTTPEnable : 1
|
||||||
@@ -222,6 +290,7 @@ describe('MacOSProxyResolver', () => {
|
|||||||
http_proxy: 'http://proxy.usebruno.com:8080',
|
http_proxy: 'http://proxy.usebruno.com:8080',
|
||||||
https_proxy: null,
|
https_proxy: null,
|
||||||
no_proxy: null,
|
no_proxy: null,
|
||||||
|
pac_url: null,
|
||||||
source: 'macos-system'
|
source: 'macos-system'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export class MacOSProxyResolver implements ProxyResolver {
|
|||||||
let http_proxy: string | null = null;
|
let http_proxy: string | null = null;
|
||||||
let https_proxy: string | null = null;
|
let https_proxy: string | null = null;
|
||||||
let no_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
|
// Check HTTP proxy
|
||||||
if (config.HTTPEnable === 1 && config.HTTPProxy) {
|
if (config.HTTPEnable === 1 && config.HTTPProxy) {
|
||||||
@@ -109,6 +115,7 @@ export class MacOSProxyResolver implements ProxyResolver {
|
|||||||
http_proxy,
|
http_proxy,
|
||||||
https_proxy,
|
https_proxy,
|
||||||
no_proxy: normalizeNoProxy(no_proxy),
|
no_proxy: normalizeNoProxy(no_proxy),
|
||||||
|
pac_url,
|
||||||
source: 'macos-system'
|
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', () => {
|
describe('WinHTTP Detection', () => {
|
||||||
it('should handle direct access configuration', async () => {
|
it('should handle direct access configuration', async () => {
|
||||||
mockExecFile
|
mockExecFile
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class WindowsProxyResolver implements ProxyResolver {
|
|||||||
let proxyEnabled = false;
|
let proxyEnabled = false;
|
||||||
let proxyServer: string | null = null;
|
let proxyServer: string | null = null;
|
||||||
let proxyOverride: string | null = null;
|
let proxyOverride: string | null = null;
|
||||||
|
let autoConfigURL: string | null = null;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
@@ -68,6 +69,19 @@ export class WindowsProxyResolver implements ProxyResolver {
|
|||||||
const match = trimmedLine.match(/ProxyOverride\s+REG_SZ\s+(.+)/);
|
const match = trimmedLine.match(/ProxyOverride\s+REG_SZ\s+(.+)/);
|
||||||
if (match) proxyOverride = match[1].trim();
|
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) {
|
if (proxyEnabled && proxyServer) {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type SystemProxyConfig = {
|
|||||||
http_proxy?: string;
|
http_proxy?: string;
|
||||||
https_proxy?: string;
|
https_proxy?: string;
|
||||||
no_proxy?: string;
|
no_proxy?: string;
|
||||||
|
pac_url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientCertificate = {
|
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
|
* `ca` to a secureContext (via addCACert) before construction, so custom CAs
|
||||||
* are added on top of the OpenSSL defaults rather than replacing them.
|
* 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;
|
private constructorOpts: any;
|
||||||
|
|
||||||
constructor(proxy: string, opts: any) {
|
constructor(proxy: string, opts: any) {
|
||||||
@@ -349,8 +350,8 @@ const getCertsAndProxyConfig = ({
|
|||||||
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
|
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
|
||||||
proxyMode = 'pac';
|
proxyMode = 'pac';
|
||||||
} else if (globalProxySource === 'inherit') {
|
} else if (globalProxySource === 'inherit') {
|
||||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
|
||||||
if (http_proxy?.length || https_proxy?.length) {
|
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
|
||||||
proxyMode = 'system';
|
proxyMode = 'system';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -362,8 +363,8 @@ const getCertsAndProxyConfig = ({
|
|||||||
// else: app-level proxy is disabled, proxyMode stays 'off'
|
// else: app-level proxy is disabled, proxyMode stays 'off'
|
||||||
} else {
|
} else {
|
||||||
// No app-level proxy config (e.g. CLI), fall through to system proxy
|
// No app-level proxy config (e.g. CLI), fall through to system proxy
|
||||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
|
||||||
if (http_proxy?.length || https_proxy?.length) {
|
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
|
||||||
proxyMode = 'system';
|
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({
|
async function createAgents({
|
||||||
requestUrl,
|
requestUrl,
|
||||||
proxyMode,
|
proxyMode,
|
||||||
@@ -459,32 +533,9 @@ async function createAgents({
|
|||||||
const pacSource = get(proxyConfig, 'pac.source');
|
const pacSource = get(proxyConfig, 'pac.source');
|
||||||
if (pacSource && requestUrl) {
|
if (pacSource && requestUrl) {
|
||||||
try {
|
try {
|
||||||
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: { ca: tlsOptions.ca, rejectUnauthorized: tlsOptions.rejectUnauthorized, minVersion: tlsOptions.minVersion } });
|
const result = await resolveAgentsFromPac({ pacSource, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
|
||||||
const directives = await resolver.resolve(requestUrl);
|
if (result.httpAgent) httpAgent = result.httpAgent;
|
||||||
if (directives && directives.length) {
|
if (result.httpsAgent) httpsAgent = result.httpsAgent;
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// PAC resolution failed — fall through to direct connection
|
// PAC resolution failed — fall through to direct connection
|
||||||
}
|
}
|
||||||
@@ -493,25 +544,37 @@ async function createAgents({
|
|||||||
const http_proxy = get(systemProxyConfig, 'http_proxy');
|
const http_proxy = get(systemProxyConfig, 'http_proxy');
|
||||||
const https_proxy = get(systemProxyConfig, 'https_proxy');
|
const https_proxy = get(systemProxyConfig, 'https_proxy');
|
||||||
const no_proxy = get(systemProxyConfig, 'no_proxy');
|
const no_proxy = get(systemProxyConfig, 'no_proxy');
|
||||||
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
|
const pac_url = get(systemProxyConfig, 'pac_url');
|
||||||
if (shouldUseSystemProxy) {
|
|
||||||
|
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
|
||||||
|
if (pac_url && requestUrl) {
|
||||||
try {
|
try {
|
||||||
if (http_proxy?.length && !isHttpsRequest) {
|
const result = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
|
||||||
const parsedHttpProxy = new URL(http_proxy);
|
if (result.httpAgent) httpAgent = result.httpAgent;
|
||||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
if (result.httpsAgent) httpsAgent = result.httpsAgent;
|
||||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
|
} catch {
|
||||||
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 {
|
} else {
|
||||||
if (https_proxy?.length && isHttpsRequest) {
|
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
|
||||||
new URL(https_proxy);
|
if (shouldUseSystemProxy) {
|
||||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
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 { getHttpHttpsAgents };
|
||||||
|
|
||||||
export type { GetHttpHttpsAgentsParams };
|
export type { GetHttpHttpsAgentsParams, ResolveAgentsFromPacParams, ResolveAgentsFromPacResult };
|
||||||
|
|||||||
@@ -3,20 +3,28 @@
|
|||||||
"name": "bruno-testbench",
|
"name": "bruno-testbench",
|
||||||
"type": "collection",
|
"type": "collection",
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"enabled": false,
|
"inherit": true,
|
||||||
"protocol": "http",
|
"config": {
|
||||||
"hostname": "{{proxyHostname}}",
|
"protocol": "http",
|
||||||
"port": 4000,
|
"hostname": "{{proxyHostname}}",
|
||||||
"auth": {
|
"port": 4000,
|
||||||
"enabled": false,
|
"auth": {
|
||||||
"username": "anoop",
|
"username": "anoop",
|
||||||
"password": "password"
|
"password": "password",
|
||||||
},
|
"disabled": true
|
||||||
"bypassProxy": ""
|
},
|
||||||
|
"bypassProxy": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"moduleWhitelist": ["crypto", "buffer", "form-data"],
|
"moduleWhitelist": [
|
||||||
"additionalContextRoots": ["../additional-context-root-lib"]
|
"crypto",
|
||||||
|
"buffer",
|
||||||
|
"form-data"
|
||||||
|
],
|
||||||
|
"additionalContextRoots": [
|
||||||
|
"../additional-context-root-lib"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"clientCertificates": {
|
"clientCertificates": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export default defineConfig({
|
|||||||
testIgnore: [
|
testIgnore: [
|
||||||
'ssl/**', // custom CA certificate tests require separate server setup and certificate generation
|
'ssl/**', // custom CA certificate tests require separate server setup and certificate generation
|
||||||
'auth/**', // auth tests have their own project
|
'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',
|
name: 'ssl',
|
||||||
testDir: './tests/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