diff --git a/package-lock.json b/package-lock.json index d5768d9b7..629cf368d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35669,4 +35669,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js index 69eecfb12..966a67c24 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js @@ -25,6 +25,40 @@ const StyledWrapper = styled.div` label { color: ${(props) => props.theme.colors.text.yellow}; } + + .system-proxy-title { + color: ${(props) => props.theme.text}; + } + + .system-proxy-description { + color: ${(props) => props.theme.colors.text.muted}; + } + + .system-proxy-error-container { + background: ${(props) => props.theme.status.danger.background}; + border: 1px solid ${(props) => props.theme.status.danger.border}; + } + + .system-proxy-error-text { + color: ${(props) => props.theme.status.danger.text}; + } + + .system-proxy-source-label { + color: ${(props) => props.theme.colors.text.muted}; + } + + .system-proxy-source-value { + color: ${(props) => props.theme.text}; + } + + .system-proxy-info-text { + color: ${(props) => props.theme.colors.text.muted}; + } + + .system-proxy-value { + color: ${(props) => props.theme.colors.text.purple}; + opacity: 0.8; + } } `; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js new file mode 100644 index 000000000..0d21aebbd --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IconLoader2 } from '@tabler/icons'; +import { getSystemProxyVariables } from 'providers/ReduxStore/slices/app'; +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 [isFetching, setIsFetching] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + dispatch(getSystemProxyVariables()) + .then(() => setError(null)) + .catch((err) => setError(err.message || String(err))) + .finally(() => setIsFetching(false)); + }, [dispatch]); + + return ( + +
+
+
+
+

+ System Proxy {isFetching ? : null} +

+ + Below values are sourced from your system proxy settings. + +
+
+
+ {error && ( +
+ + Error loading system proxy settings: {error} + +
+ )} + {source && ( +
+ +
+ Proxy source: +
+
+ {source} +
+
+
+ )} + + These values cannot be directly updated in Bruno. Please refer to your OS documentation to update these. + +
+
+ +
{http_proxy || '-'}
+
+
+ +
{https_proxy || '-'}
+
+
+ +
{no_proxy || '-'}
+
+
+
+
+ ); +}; + +export default SystemProxy; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js index 2601093ca..241c7a7cb 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js @@ -9,13 +9,11 @@ import StyledWrapper from './StyledWrapper'; import { useDispatch, useSelector } from 'react-redux'; import { IconEye, IconEyeOff } from '@tabler/icons'; import { useState } from 'react'; +import SystemProxy from './SystemProxy'; const ProxySettings = ({ close }) => { const preferences = useSelector((state) => state.app.preferences); - const systemProxyEnvVariables = useSelector((state) => state.app.systemProxyEnvVariables); - const { http_proxy, https_proxy, no_proxy } = systemProxyEnvVariables || {}; const dispatch = useDispatch(); - console.log(preferences); const proxySchema = Yup.object({ disabled: Yup.boolean().optional(), @@ -167,30 +165,7 @@ const ProxySettings = ({ close }) => { {formik.values.disabled === false && formik.values.inherit === true ? (
- - Below values are sourced from your system environment variables and cannot be directly updated in Bruno.
- Please refer to your OS documentation to change these values. -
-
-
- -
{http_proxy || '-'}
-
-
- -
{https_proxy || '-'}
-
-
- -
{no_proxy || '-'}
-
-
+
) : null} {formik.values.disabled === false && formik.values.inherit === false ? ( diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 57ad166b5..4b256555a 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -1,8 +1,7 @@ import { useEffect } from 'react'; import { updateCookies, - updatePreferences, - updateSystemProxyEnvVariables + updatePreferences } from 'providers/ReduxStore/slices/app'; import { addTab @@ -271,10 +270,6 @@ const useIpcEvents = () => { dispatch(updatePreferences(val)); }); - const removeSystemProxyEnvUpdatesListener = ipcRenderer.on('main:load-system-proxy-env', (val) => { - dispatch(updateSystemProxyEnvVariables(val)); - }); - const removeCookieUpdateListener = ipcRenderer.on('main:cookies-update', (val) => { dispatch(updateCookies(val)); }); @@ -331,7 +326,6 @@ const useIpcEvents = () => { removeShowPreferencesListener(); removePreferencesUpdatesListener(); removeCookieUpdateListener(); - removeSystemProxyEnvUpdatesListener(); removeGlobalEnvironmentsUpdatesListener(); removeSnapshotHydrationListener(); removeCollectionOauth2CredentialsUpdatesListener(); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 32bf792d5..d70f6c0a1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -48,10 +48,10 @@ const initialState = { }, cookies: [], taskQueue: [], - systemProxyEnvVariables: {}, clipboard: { hasCopiedItems: false // Whether clipboard has Bruno data (for UI) - } + }, + systemProxyVariables: {} }; export const appSlice = createSlice({ @@ -111,8 +111,8 @@ export const appSlice = createSlice({ removeAllTasksFromQueue: (state) => { state.taskQueue = []; }, - updateSystemProxyEnvVariables: (state, action) => { - state.systemProxyEnvVariables = action.payload; + updateSystemProxyVariables: (state, action) => { + state.systemProxyVariables = action.payload; }, updateGenerateCode: (state, action) => { state.generateCode = { @@ -161,7 +161,7 @@ export const { insertTaskIntoQueue, removeTaskFromQueue, removeAllTasksFromQueue, - updateSystemProxyEnvVariables, + updateSystemProxyVariables, updateGenerateCode, toggleSidebarCollapse, setClipboard @@ -236,4 +236,16 @@ export const copyRequest = (item) => (dispatch, getState) => { return Promise.resolve(); }; +export const getSystemProxyVariables = () => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:get-system-proxy-variables') + .then((variables) => { + dispatch(updateSystemProxyVariables(variables)); + return variables; + }) + .then(resolve).catch(reject); + }); +}; + export default appSlice.reducer; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 5fb8de3a7..f5dc104ee 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -14,7 +14,8 @@ const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { makeAxiosInstance } = require('../utils/axios-instance'); const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper'); -const { shouldUseProxy, PatchedHttpsProxyAgent, getSystemProxyEnvVariables } = require('../utils/proxy-util'); +const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util'); +const { getSystemProxy } = require('@usebruno/requests'); const path = require('path'); const { parseDataFromResponse } = require('../utils/common'); const { getCookieStringForUrl, saveCookies } = require('../utils/cookies'); @@ -302,7 +303,8 @@ const runSingleRequest = async function ( proxyMode = 'on'; } else if (!collectionProxyDisabled && collectionProxyInherit) { // Inherit from system proxy - const { http_proxy, https_proxy } = getSystemProxyEnvVariables(); + const systemProxy = await getSystemProxy(); + const { http_proxy, https_proxy } = systemProxy; if (http_proxy?.length || https_proxy?.length) { proxyMode = 'system'; } @@ -347,33 +349,40 @@ const runSingleRequest = async function ( }); } } else if (proxyMode === 'system') { - const { http_proxy, https_proxy, no_proxy } = getSystemProxyEnvVariables(); - const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || ''); - if (shouldUseSystemProxy) { - try { - if (http_proxy?.length) { - new URL(http_proxy); - request.httpAgent = new HttpProxyAgent(http_proxy); + try { + const systemProxy = await getSystemProxy(); + const { http_proxy, https_proxy, no_proxy } = systemProxy; + const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || ''); + const parsedUrl = new URL(request.url); + const isHttpsRequest = parsedUrl.protocol === 'https:'; + if (shouldUseSystemProxy) { + try { + if (http_proxy?.length && !isHttpsRequest) { + new URL(http_proxy); + request.httpAgent = new HttpProxyAgent(http_proxy); + } + } catch (error) { + throw new Error('Invalid system http_proxy'); } - } catch (error) { - throw new Error('Invalid system http_proxy'); - } - try { - if (https_proxy?.length) { - new URL(https_proxy); - request.httpsAgent = new PatchedHttpsProxyAgent( - https_proxy, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - } else { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); + try { + if (https_proxy?.length && isHttpsRequest) { + new URL(https_proxy); + request.httpsAgent = new PatchedHttpsProxyAgent(https_proxy, + Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined); + } else { + request.httpsAgent = new https.Agent({ + ...httpsAgentRequestFields + }); + } + } catch (error) { + throw new Error('Invalid system https_proxy'); } - } catch (error) { - throw new Error('Invalid system https_proxy'); + } else { + request.httpsAgent = new https.Agent({ + ...httpsAgentRequestFields + }); } - } else { + } catch (error) { request.httpsAgent = new https.Agent({ ...httpsAgentRequestFields }); @@ -488,6 +497,7 @@ const runSingleRequest = async function ( const proxyConfig = get(brunoConfig, 'proxy'); const interpolatedClientCertificates = clientCertificates ? interpolateObject(clientCertificates, oauth2InterpolationOptions) : undefined; const interpolatedProxyConfig = proxyConfig ? interpolateObject(proxyConfig, oauth2InterpolationOptions) : undefined; + const systemProxyConfig = await getSystemProxy(); const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({ requestUrl: oauth2RequestUrl, @@ -495,7 +505,7 @@ const runSingleRequest = async function ( options: tlsOptions, clientCertificates: interpolatedClientCertificates, collectionLevelProxy: interpolatedProxyConfig, - systemProxyConfig: getSystemProxyEnvVariables() + systemProxyConfig }); const oauth2AxiosInstance = makeAxiosInstanceForOauth2({ diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js index 3d3be5045..729e03356 100644 --- a/packages/bruno-cli/src/utils/proxy-util.js +++ b/packages/bruno-cli/src/utils/proxy-util.js @@ -79,17 +79,7 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } } -const getSystemProxyEnvVariables = () => { - const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env; - return { - http_proxy: http_proxy || HTTP_PROXY, - https_proxy: https_proxy || HTTPS_PROXY, - no_proxy: no_proxy || NO_PROXY - }; -}; - module.exports = { shouldUseProxy, - PatchedHttpsProxyAgent, - getSystemProxyEnvVariables + PatchedHttpsProxyAgent }; diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js index e3cea3fb9..def250f14 100644 --- a/packages/bruno-electron/src/ipc/network/cert-utils.js +++ b/packages/bruno-electron/src/ipc/network/cert-utils.js @@ -1,7 +1,7 @@ const fs = require('node:fs'); const path = require('path'); const { get } = require('lodash'); -const { getCACertificates } = require('@usebruno/requests'); +const { getCACertificates, getSystemProxy } = require('@usebruno/requests'); const { preferencesUtil } = require('../../store/preferences'); const { getBrunoConfig } = require('../../store/bruno-config'); const { interpolateString } = require('./interpolate-string'); @@ -144,6 +144,8 @@ const getCertsAndProxyConfig = async ({ } else if (!globalDisabled && globalInherit) { // Use system proxy proxyMode = 'system'; + const systemProxyConfig = await getSystemProxy(); + proxyConfig = systemProxyConfig; } // else: global proxy is disabled, proxyMode stays 'off' } diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js index 4d8f7dea1..607eec6ad 100644 --- a/packages/bruno-electron/src/ipc/preferences.js +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -1,18 +1,14 @@ const { ipcMain, nativeTheme } = require('electron'); -const { getPreferences, savePreferences, preferencesUtil } = require('../store/preferences'); +const { getPreferences, savePreferences } = require('../store/preferences'); const { globalEnvironmentsStore } = require('../store/global-environments'); +const { getSystemProxy } = require('@usebruno/requests'); -const registerPreferencesIpc = (mainWindow, watcher) => { +const registerPreferencesIpc = (mainWindow) => { ipcMain.handle('renderer:ready', async (event) => { // load preferences const preferences = getPreferences(); mainWindow.webContents.send('main:load-preferences', preferences); - // load system proxy vars - const systemProxyVars = preferencesUtil.getSystemProxyEnvVariables(); - const { http_proxy, https_proxy, no_proxy } = systemProxyVars || {}; - mainWindow.webContents.send('main:load-system-proxy-env', { http_proxy, https_proxy, no_proxy }); - try { // load global environments const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments(); @@ -42,6 +38,11 @@ const registerPreferencesIpc = (mainWindow, watcher) => { ipcMain.on('renderer:theme-change', (event, theme) => { nativeTheme.themeSource = theme; }); + + ipcMain.handle('renderer:get-system-proxy-variables', async () => { + const systemProxyConfig = await getSystemProxy(); + return systemProxyConfig; + }); }; module.exports = registerPreferencesIpc; diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index b8a815f32..1fc04b5f3 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -124,9 +124,27 @@ class PreferencesStore { getPreferences() { let preferences = this.store.get('preferences', {}); - // Migrate proxy configuration from old formats to new format - const proxyMigrated = get(preferences, '_migrations.proxyConfigFormat', false); - if (!proxyMigrated && preferences?.proxy) { + // Handle existing users without proxy settings + // They should get disabled proxy by default, not inherit from system + // New users (empty preferences) will get defaultPreferences.proxy via merge + if (Object.keys(preferences).length > 0 && !preferences.proxy) { + preferences.proxy = { + inherit: false, + disabled: true, + config: { + protocol: 'http', + hostname: '', + port: null, + auth: { + username: '', + password: '' + }, + bypassProxy: '' + } + }; + } + + if (preferences?.proxy) { const proxy = preferences.proxy || {}; // Check if this is an old format that needs migration @@ -181,15 +199,6 @@ class PreferencesStore { } preferences.proxy = newProxy; - - // Mark migration as complete // ? - // if (!preferences._migrations) { - // preferences._migrations = {}; - // } - // preferences._migrations.proxyConfigFormat = true; - - // Save the migrated preferences back to the store - // this.store.set('preferences', preferences); } } @@ -260,7 +269,7 @@ const preferencesUtil = { return get(getPreferences(), 'request.timeout', 0); }, getGlobalProxyConfig: () => { - return get(getPreferences(), 'proxy', {}); + return get(getPreferences(), 'proxy', defaultPreferences.proxy); }, shouldStoreCookies: () => { return get(getPreferences(), 'request.storeCookies', true); @@ -274,14 +283,6 @@ const preferencesUtil = { getResponsePaneOrientation: () => { return get(getPreferences(), 'layout.responsePaneOrientation', 'horizontal'); }, - getSystemProxyEnvVariables: () => { - const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env; - return { - http_proxy: http_proxy || HTTP_PROXY, - https_proxy: https_proxy || HTTPS_PROXY, - no_proxy: no_proxy || NO_PROXY - }; - }, isBetaFeatureEnabled: (featureName) => { return get(getPreferences(), `beta.${featureName}`, false); }, diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js index 06e5c90d2..5730968ac 100644 --- a/packages/bruno-electron/src/utils/proxy-util.js +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -4,7 +4,6 @@ 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 { preferencesUtil } = require('../store/preferences'); const { isEmpty, get, isUndefined, isNull } = require('lodash'); const DEFAULT_PORTS = { @@ -84,6 +83,29 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } } +function createTimelineHttpAgentClass(BaseAgentClass) { + return class extends BaseAgentClass { + constructor(options, timeline) { + // For proxy agents, the first argument is the proxy URI and the second is options + const { proxy: proxyUri, httpProxyAgentOptions } = options || {}; + + if (!proxyUri) { + throw new Error('TimelineHttpProxyAgent requires options.proxy to be set'); + } + + super(proxyUri, httpProxyAgentOptions); + + this.timeline = Array.isArray(timeline) ? timeline : []; + // Log the proxy details + this.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Using proxy: ${proxyUri}` + }); + } + }; +} + function createTimelineAgentClass(BaseAgentClass) { return class extends BaseAgentClass { constructor(options, timeline) { @@ -312,6 +334,10 @@ function setupProxyAgents({ rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true }; + const httpProxyAgentOptions = { + keepAlive: true + }; + if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', '')); if (shouldProxy) { @@ -337,8 +363,9 @@ function setupProxyAgents({ requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline); requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline); } else { + const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent); const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent); - requestConfig.httpAgent = new HttpProxyAgent(proxyUri); // For http, no need for timeline + requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: proxyUri, httpProxyAgentOptions }, timeline); requestConfig.httpsAgent = new TimelineHttpsProxyAgent( { proxy: proxyUri, ...tlsOptions }, timeline @@ -350,19 +377,22 @@ function setupProxyAgents({ requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } else if (proxyMode === 'system') { - const { http_proxy, https_proxy, no_proxy } = preferencesUtil.getSystemProxyEnvVariables(); + const { http_proxy, https_proxy, no_proxy } = proxyConfig || {}; const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || ''); + const parsedUrl = parseUrl(requestConfig.url); + const isHttpsRequest = parsedUrl.protocol === 'https:'; if (shouldUseSystemProxy) { try { - if (http_proxy?.length) { + if (http_proxy?.length && !isHttpsRequest) { new URL(http_proxy); - requestConfig.httpAgent = new HttpProxyAgent(http_proxy); + const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent); + requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: http_proxy, httpProxyAgentOptions }, timeline); } } catch (error) { - throw new Error('Invalid system http_proxy'); + throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`); } try { - if (https_proxy?.length) { + if (https_proxy?.length && isHttpsRequest) { new URL(https_proxy); const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent); requestConfig.httpsAgent = new TimelineHttpsProxyAgent( @@ -374,7 +404,7 @@ function setupProxyAgents({ requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } catch (error) { - throw new Error('Invalid system https_proxy'); + throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`); } } else { const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); diff --git a/packages/bruno-electron/tests/store/proxy-preferences.spec.js b/packages/bruno-electron/tests/store/proxy-preferences.spec.js index cc202c9b3..927ce2032 100644 --- a/packages/bruno-electron/tests/store/proxy-preferences.spec.js +++ b/packages/bruno-electron/tests/store/proxy-preferences.spec.js @@ -19,6 +19,49 @@ describe('Proxy Preferences Migration', () => { mockStoreData = {}; }); + describe('Default Proxy Settings', () => { + it('should default to inherit: true for new users (empty preferences)', () => { + // New user - no preferences.json exists, store returns empty object + mockStoreData['preferences'] = {}; + + const preferences = getPreferences(); + + // New users get the default proxy settings with inherit: true + expect(preferences.proxy.inherit).toBe(true); + expect(preferences.proxy.disabled).toBeUndefined(); + expect(preferences.proxy.config).toBeDefined(); + expect(preferences.proxy.config.protocol).toBe('http'); + expect(preferences.proxy.config.hostname).toBe(''); + expect(preferences.proxy.config.port).toBeNull(); + }); + + it('should default to disabled: true, inherit: false for existing users without proxy settings', () => { + // Existing user - has preferences but no proxy property + mockStoreData['preferences'] = { + request: { + sslVerification: true + }, + font: { + codeFont: 'default', + codeFontSize: 13 + } + }; + + const preferences = getPreferences(); + + // Existing users without proxy get disabled proxy by default + expect(preferences.proxy.disabled).toBe(true); + expect(preferences.proxy.inherit).toBe(false); + expect(preferences.proxy.config).toBeDefined(); + expect(preferences.proxy.config.protocol).toBe('http'); + expect(preferences.proxy.config.hostname).toBe(''); + expect(preferences.proxy.config.port).toBeNull(); + expect(preferences.proxy.config.auth.username).toBe(''); + expect(preferences.proxy.config.auth.password).toBe(''); + expect(preferences.proxy.config.bypassProxy).toBe(''); + }); + }); + describe('New Format (no migration needed)', () => { it('should handle new format with inherit: false', () => { const newFormatProxy = { diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index 1da7b035a..51f201545 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -11,4 +11,4 @@ export { getHttpHttpsAgents } from './utils/http-https-agents'; export * as scripting from './scripting'; -export { makeAxiosInstance } from './network/axios-instance'; +export { makeAxiosInstance, getSystemProxy } from './network'; diff --git a/packages/bruno-requests/src/network/index.ts b/packages/bruno-requests/src/network/index.ts index 19548a4e5..374bef735 100644 --- a/packages/bruno-requests/src/network/index.ts +++ b/packages/bruno-requests/src/network/index.ts @@ -1 +1,3 @@ export { makeAxiosInstance } from './axios-instance'; + +export { getSystemProxy } from './system-proxy'; diff --git a/packages/bruno-requests/src/network/system-proxy/index.spec.js b/packages/bruno-requests/src/network/system-proxy/index.spec.js new file mode 100644 index 000000000..2438d566b --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/index.spec.js @@ -0,0 +1,270 @@ +const { getSystemProxy, SystemProxyResolver } = require('./index'); +const os = require('node:os'); + +// Mock dependencies +jest.mock('node:os'); +jest.mock('./utils/windows'); +jest.mock('./utils/macos'); +jest.mock('./utils/linux'); + +describe('SystemProxyResolver Integration', () => { + let detector; + let originalEnv; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + detector = new SystemProxyResolver(); + originalEnv = { ...process.env }; + + // Clear environment variables + delete process.env.http_proxy; + delete process.env.HTTP_PROXY; + delete process.env.https_proxy; + delete process.env.HTTPS_PROXY; + delete process.env.no_proxy; + delete process.env.NO_PROXY; + }); + + afterEach(() => { + process.env = originalEnv; + jest.runOnlyPendingTimers(); + // Clear any pending timers to prevent Jest open handles + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('Environment Variables', () => { + it('should prioritize lowercase over uppercase variables', () => { + process.env.http_proxy = 'http://proxy.usebruno.com:8080'; + process.env.HTTP_PROXY = 'http://proxy.usebruno.com:8081'; + process.env.https_proxy = 'https://proxy.usebruno.com:8082'; + process.env.HTTPS_PROXY = 'https://proxy.usebruno.com:8083'; + + const result = detector.getEnvironmentVariables(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8082', + no_proxy: null, + source: 'environment' + }); + }); + + it('should fall back to uppercase when lowercase is not set', () => { + process.env.HTTP_PROXY = 'http://proxy.usebruno.com:8081'; + process.env.NO_PROXY = 'localhost,127.0.0.1'; + + const result = detector.getEnvironmentVariables(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8081', + https_proxy: null, + no_proxy: 'localhost,127.0.0.1', + source: 'environment' + }); + }); + + it('should return null values when no environment variables are set', () => { + const result = detector.getEnvironmentVariables(); + + expect(result).toEqual({ + http_proxy: null, + https_proxy: null, + no_proxy: null, + source: 'environment' + }); + }); + }); + + describe('Platform Routing', () => { + it('should route to Windows detector on win32', async () => { + // Create a new detector instance after mocking the platform + os.platform.mockReturnValue('win32'); + const testResolver = new SystemProxyResolver(); + const { WindowsProxyResolver } = require('./utils/windows'); + const mockDetect = jest.fn().mockResolvedValue({ source: 'windows-system' }); + WindowsProxyResolver.mockImplementation(() => ({ detect: mockDetect })); + + await testResolver.getSystemProxy(); + expect(mockDetect).toHaveBeenCalled(); + }); + + it('should route to macOS detector on darwin', async () => { + // Create a new detector instance after mocking the platform + os.platform.mockReturnValue('darwin'); + const testResolver = new SystemProxyResolver(); + const { MacOSProxyResolver } = require('./utils/macos'); + const mockDetect = jest.fn().mockResolvedValue({ source: 'macos-system' }); + MacOSProxyResolver.mockImplementation(() => ({ detect: mockDetect })); + + await testResolver.getSystemProxy(); + expect(mockDetect).toHaveBeenCalled(); + }); + + it('should route to Linux detector on linux', async () => { + // Create a new detector instance after mocking the platform + os.platform.mockReturnValue('linux'); + const testResolver = new SystemProxyResolver(); + const { LinuxProxyResolver } = require('./utils/linux'); + const mockDetect = jest.fn().mockResolvedValue({ source: 'linux-system' }); + LinuxProxyResolver.mockImplementation(() => ({ detect: mockDetect })); + + await testResolver.getSystemProxy(); + expect(mockDetect).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('should throw error when platform detection fails', async () => { + os.platform.mockReturnValue('win32'); + const testResolver = new SystemProxyResolver(); + const { WindowsProxyResolver } = require('./utils/windows'); + WindowsProxyResolver.mockImplementation(() => ({ + detect: jest.fn().mockRejectedValue(new Error('Detection failed')) + })); + + await expect(testResolver.getSystemProxy()).rejects.toThrow('Detection failed'); + }); + + it('should throw error on timeout', async () => { + os.platform.mockReturnValue('win32'); + const testResolver = new SystemProxyResolver({ commandTimeoutMs: 100 }); + const { WindowsProxyResolver } = require('./utils/windows'); + + // Mock a detector that throws a timeout error + WindowsProxyResolver.mockImplementation(() => ({ + detect: jest.fn().mockRejectedValue(new Error('System proxy detection timeout')) + })); + + await expect(testResolver.getSystemProxy()).rejects.toThrow('System proxy detection timeout'); + }); + + it('should throw error for unsupported platform', async () => { + os.platform.mockReturnValue('freebsd'); + const testResolver = new SystemProxyResolver(); + + await expect(testResolver.getSystemProxy()).rejects.toThrow('Unsupported platform: freebsd'); + }); + }); + + describe('getSystemProxy function', () => { + beforeEach(() => { + // Reset modules to ensure fresh imports for each test + jest.resetModules(); + }); + + it('should merge environment variables with system proxy', async () => { + // Mock os.platform before requiring the module + jest.doMock('node:os', () => ({ + platform: jest.fn().mockReturnValue('win32') + })); + + const { WindowsProxyResolver } = require('./utils/windows'); + WindowsProxyResolver.mockImplementation(() => ({ + detect: jest.fn().mockResolvedValue({ + http_proxy: 'http://system-proxy.usebruno.com:8080', + https_proxy: 'https://system-proxy.usebruno.com:8443', + no_proxy: 'localhost', + source: 'windows-system' + }) + })); + + process.env.http_proxy = 'http://env-proxy.usebruno.com:9090'; + + // Require the module after mocking + const { getSystemProxy: getSystemProxyFresh } = require('./index'); + const result = await getSystemProxyFresh(); + + // Environment variables take priority + expect(result).toEqual({ + http_proxy: 'http://env-proxy.usebruno.com:9090', + https_proxy: 'https://system-proxy.usebruno.com:8443', + no_proxy: 'localhost', + source: 'windows-system + environment' + }); + }); + + it('should return only system proxy when no environment variables are set', async () => { + // Mock os.platform before requiring the module + jest.doMock('node:os', () => ({ + platform: jest.fn().mockReturnValue('darwin') + })); + + const { MacOSProxyResolver } = require('./utils/macos'); + MacOSProxyResolver.mockImplementation(() => ({ + detect: jest.fn().mockResolvedValue({ + http_proxy: 'http://system-proxy.usebruno.com:8080', + https_proxy: 'https://system-proxy.usebruno.com:8443', + no_proxy: 'localhost', + source: 'macos-system' + }) + })); + + // Require the module after mocking + const { getSystemProxy: getSystemProxyFresh } = require('./index'); + const result = await getSystemProxyFresh(); + + expect(result).toEqual({ + http_proxy: 'http://system-proxy.usebruno.com:8080', + https_proxy: 'https://system-proxy.usebruno.com:8443', + no_proxy: 'localhost', + source: 'macos-system' + }); + }); + + it('should fallback to environment variables when system detection fails', async () => { + // Mock os.platform before requiring the module + jest.doMock('node:os', () => ({ + platform: jest.fn().mockReturnValue('linux') + })); + + const { LinuxProxyResolver } = require('./utils/linux'); + LinuxProxyResolver.mockImplementation(() => ({ + detect: jest.fn().mockRejectedValue(new Error('Detection failed')) + })); + + process.env.http_proxy = 'http://proxy.usebruno.com:8080'; + process.env.https_proxy = 'https://proxy.usebruno.com:8443'; + + // Require the module after mocking + const { getSystemProxy: getSystemProxyFresh } = require('./index'); + const result = await getSystemProxyFresh(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8443', + no_proxy: null, + source: 'environment' + }); + }); + + it('should return null values when no proxy is configured', async () => { + // Mock os.platform before requiring the module + jest.doMock('node:os', () => ({ + platform: jest.fn().mockReturnValue('darwin') + })); + + const { MacOSProxyResolver } = require('./utils/macos'); + MacOSProxyResolver.mockImplementation(() => ({ + detect: jest.fn().mockResolvedValue({ + http_proxy: null, + https_proxy: null, + no_proxy: null, + source: 'macos-system' + }) + })); + + // Require the module after mocking + const { getSystemProxy: getSystemProxyFresh } = require('./index'); + const result = await getSystemProxyFresh(); + + expect(result).toEqual({ + http_proxy: null, + https_proxy: null, + no_proxy: null, + source: 'macos-system' + }); + }); + }); +}); diff --git a/packages/bruno-requests/src/network/system-proxy/index.ts b/packages/bruno-requests/src/network/system-proxy/index.ts new file mode 100644 index 000000000..07f9861cc --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/index.ts @@ -0,0 +1,107 @@ +import { platform } from 'node:os'; +import { ProxyConfiguration, SystemProxyResolverOptions } from './types'; +import { WindowsProxyResolver } from './utils/windows'; +import { MacOSProxyResolver } from './utils/macos'; +import { LinuxProxyResolver } from './utils/linux'; +import { normalizeNoProxy, normalizeProxyUrl } from './utils/common'; + +export class SystemProxyResolver { + private osPlatform: string; + private commandTimeoutMs: number = 10000; + private activeDetection: Promise | null = null; + + constructor(options: SystemProxyResolverOptions = {}) { + this.osPlatform = platform(); + if (options.commandTimeoutMs) { + this.commandTimeoutMs = options.commandTimeoutMs; + } + } + + async getSystemProxy(): Promise { + // Return active detection if already in progress + if (this.activeDetection) { + return this.activeDetection; + } + + // Start new detection + this.activeDetection = this.detectSystemProxy(); + + try { + const result = await this.activeDetection; + return result; + } finally { + this.activeDetection = null; + } + } + + private async detectSystemProxy(): Promise { + const startTime = Date.now(); + + try { + const result = await this.detectByPlatform(); + + // Log slow detections + const detectionTime = Date.now() - startTime; + if (detectionTime > 5000) { + console.warn(`System proxy detection took ${detectionTime}ms`); + } + + return result; + } catch (error) { + console.warn(`System proxy detection failed after ${Date.now() - startTime}ms:`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + + private async detectByPlatform(): Promise { + switch (this.osPlatform) { + case 'win32': + return await new WindowsProxyResolver().detect({ timeoutMs: this.commandTimeoutMs }); + case 'darwin': + return await new MacOSProxyResolver().detect({ timeoutMs: this.commandTimeoutMs }); + case 'linux': + return await new LinuxProxyResolver().detect({ timeoutMs: this.commandTimeoutMs }); + default: + throw new Error(`Unsupported platform: ${this.osPlatform}`); + } + } + + getEnvironmentVariables(): ProxyConfiguration { + const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY, all_proxy, ALL_PROXY } = process.env; + + const httpProxy = http_proxy || HTTP_PROXY || all_proxy || ALL_PROXY || ''; + const httpsProxy = https_proxy || HTTPS_PROXY || all_proxy || ALL_PROXY || ''; + const noProxy = no_proxy || NO_PROXY || ''; + + return { + http_proxy: httpProxy ? normalizeProxyUrl(httpProxy) : null, + https_proxy: httpsProxy ? normalizeProxyUrl(httpsProxy, 'https') : null, + no_proxy: noProxy ? normalizeNoProxy(noProxy) : null, + source: 'environment' + }; + } +} + +const systemProxyResolver = new SystemProxyResolver(); + +export async function getSystemProxy(): Promise { + const proxyEnvironmentVariables = systemProxyResolver.getEnvironmentVariables(); + + const hasEnvironmentProxy = proxyEnvironmentVariables.http_proxy || proxyEnvironmentVariables.https_proxy; + + try { + const systemProxyEnvironmentVariables = await systemProxyResolver.getSystemProxy(); + + return { + http_proxy: proxyEnvironmentVariables?.http_proxy || systemProxyEnvironmentVariables?.http_proxy, + https_proxy: proxyEnvironmentVariables?.https_proxy || systemProxyEnvironmentVariables?.https_proxy, + no_proxy: proxyEnvironmentVariables?.no_proxy || systemProxyEnvironmentVariables?.no_proxy, + source: hasEnvironmentProxy ? `${systemProxyEnvironmentVariables?.source} + environment` : systemProxyEnvironmentVariables?.source + }; + } catch (error) { + console.error('Error getting system proxy:', error); + return proxyEnvironmentVariables; + } +} + +export { ProxyConfiguration } from './types'; diff --git a/packages/bruno-requests/src/network/system-proxy/types.ts b/packages/bruno-requests/src/network/system-proxy/types.ts new file mode 100644 index 000000000..a438691ae --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/types.ts @@ -0,0 +1,14 @@ +export interface ProxyConfiguration { + http_proxy?: string | null; + https_proxy?: string | null; + no_proxy?: string | null; + source: string; +}; + +export interface ProxyResolver { + detect(opts?: { timeoutMs?: number }): Promise; +} + +export interface SystemProxyResolverOptions { + commandTimeoutMs?: number; +} diff --git a/packages/bruno-requests/src/network/system-proxy/utils/common.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/common.spec.ts new file mode 100644 index 000000000..a122ae4dd --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/common.spec.ts @@ -0,0 +1,64 @@ +import { normalizeProxyUrl, normalizeNoProxy } from './common'; + +describe('normalizeProxyUrl', () => { + it('should add http protocol when missing', () => { + expect(normalizeProxyUrl('proxy.usebruno.com:8080')).toBe('http://proxy.usebruno.com:8080'); + }); + + it('should not modify URL with existing protocol', () => { + expect(normalizeProxyUrl('http://proxy.usebruno.com:8080')).toBe('http://proxy.usebruno.com:8080'); + expect(normalizeProxyUrl('https://proxy.usebruno.com:8443')).toBe('https://proxy.usebruno.com:8443'); + }); + + it('should handle empty string', () => { + expect(normalizeProxyUrl('')).toBe(''); + }); + + it('should handle various protocols', () => { + expect(normalizeProxyUrl('socks5://proxy.usebruno.com:1080')).toBe('socks5://proxy.usebruno.com:1080'); + expect(normalizeProxyUrl('socks4://proxy.usebruno.com:1080')).toBe('socks4://proxy.usebruno.com:1080'); + }); + + it('should handle URLs without port', () => { + expect(normalizeProxyUrl('proxy.usebruno.com')).toBe('http://proxy.usebruno.com'); + }); +}); + +describe('normalizeNoProxy', () => { + it('should normalize comma-separated list', () => { + expect(normalizeNoProxy('localhost,127.0.0.1')).toBe('localhost,127.0.0.1'); + }); + + it('should convert semicolons to commas', () => { + expect(normalizeNoProxy('localhost;127.0.0.1')).toBe('localhost,127.0.0.1'); + }); + + it('should handle mixed delimiters', () => { + expect(normalizeNoProxy('localhost;127.0.0.1,*.local')).toBe('localhost,127.0.0.1,*.local'); + }); + + it('should trim whitespace', () => { + expect(normalizeNoProxy('localhost , 127.0.0.1 ; *.local')).toBe('localhost,127.0.0.1,*.local'); + }); + + it('should remove empty entries', () => { + expect(normalizeNoProxy('localhost,,127.0.0.1')).toBe('localhost,127.0.0.1'); + expect(normalizeNoProxy('localhost; ;127.0.0.1')).toBe('localhost,127.0.0.1'); + }); + + it('should handle null input', () => { + expect(normalizeNoProxy(null)).toBeNull(); + }); + + it('should handle empty string', () => { + expect(normalizeNoProxy('')).toBeNull(); + }); + + it('should handle whitespace-only string', () => { + expect(normalizeNoProxy(' ')).toBeNull(); + }); + + it('should handle complex patterns', () => { + expect(normalizeNoProxy('localhost;127.0.0.1;*.local;192.168.1.0/24;')).toBe('localhost,127.0.0.1,*.local,192.168.1.0/24,'); + }); +}); diff --git a/packages/bruno-requests/src/network/system-proxy/utils/common.ts b/packages/bruno-requests/src/network/system-proxy/utils/common.ts new file mode 100644 index 000000000..68594cc84 --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/common.ts @@ -0,0 +1,63 @@ +import { execFile, ExecFileOptions } from 'node:child_process'; +import { promisify } from 'node:util'; + +export const execFileAsync = promisify(execFile); + +/** + * Safely execute a command without shell interpretation. + * Returns stdout on success, null on error. + * + * @param bin - The binary/command to execute + * @param args - Array of arguments to pass to the command + * @param opts - ExecFileOptions (timeout, maxBuffer, etc.) + * @returns stdout trimmed on success, null on error + */ +export async function safeExec(bin: string, args: string[], opts: ExecFileOptions): Promise { + try { + const { stdout } = await execFileAsync(bin, args, opts); + return stdout.trim(); + } catch { + return null; + } +} + +/** + * Normalizes a proxy URL by ensuring it includes a protocol. + * @param proxy - The proxy URL to normalize (e.g., "proxy.usebruno.com:8080"). + * @param defaultProtocol - The protocol to prepend if missing (default: "http"). + * @returns The normalized proxy URL (e.g., "http://proxy.usebruno.com:8080"). + * + * Notes: + * - If the URL already includes a protocol (e.g., "https://..."), it is returned unchanged. + * - When system proxy settings omit the protocol, + * this function cannot infer the original protocol and will apply the default ("http"). + */ + +export const normalizeProxyUrl = (proxy: string, defaultProtocol: string = 'http'): string => { + if (!proxy) return proxy; + + // Check if proxy already has a protocol (must have :// after protocol name) + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(proxy)) { + return proxy; + } + + // Add default protocol + return `${defaultProtocol}://${proxy}`; +}; + +/** + * Normalizes no_proxy list to comma-separated format + * @param noProxy - The no_proxy string (e.g., "localhost;127.0.0.1") + * @returns Normalized comma-separated no_proxy list (e.g., "localhost,127.0.0.1") + */ +export const normalizeNoProxy = (noProxy: string | null): string | null => { + if (!noProxy) return null; + + const normalized = noProxy + .split(/[;,\s]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .join(','); + + return normalized || null; +}; diff --git a/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts new file mode 100644 index 000000000..f2d2bcb53 --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts @@ -0,0 +1,223 @@ +import { LinuxProxyResolver } from './linux'; + +// Mock the entire child_process module +jest.mock('node:child_process', () => ({ + execFile: jest.fn() +})); + +// Mock the fs/promises module +jest.mock('node:fs/promises', () => ({ + readFile: jest.fn() +})); + +// Mock the fs module +jest.mock('node:fs', () => ({ + existsSync: jest.fn() +})); + +// Mock the util module +jest.mock('node:util', () => ({ + promisify: jest.fn((fn) => fn) +})); + +describe('LinuxProxyResolver', () => { + let detector: LinuxProxyResolver; + let mockExecFile: jest.MockedFunction; + let mockReadFile: jest.MockedFunction; + let mockExistsSync: jest.MockedFunction; + + beforeEach(() => { + detector = new LinuxProxyResolver(); + const { execFile } = require('node:child_process'); + const { readFile } = require('node:fs/promises'); + const { existsSync } = require('node:fs'); + mockExecFile = execFile; + mockReadFile = readFile; + mockExistsSync = existsSync; + jest.clearAllMocks(); + }); + + describe('gsettings proxy detection', () => { + it('should detect manual proxy configuration', async () => { + const modeOutput = '\'manual\''; + const httpHostOutput = '\'proxy.usebruno.com\''; + const httpPortOutput = '8080'; + const httpsHostOutput = '\'secure-proxy.usebruno.com\''; + const httpsPortOutput = '8443'; + const ignoreHostsOutput = '[\'localhost\', \'127.0.0.1\']'; + + mockExecFile + .mockResolvedValueOnce({ stdout: modeOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpHostOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpPortOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpsHostOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpsPortOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: ignoreHostsOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://secure-proxy.usebruno.com:8443', + no_proxy: 'localhost,127.0.0.1', + source: 'linux-system' + }); + }); + + it('should detect identical HTTP and HTTPS proxies', async () => { + const modeOutput = '\'manual\''; + const httpHostOutput = '\'proxy.usebruno.com\''; + const httpPortOutput = '8080'; + const httpsHostOutput = '\'proxy.usebruno.com\''; + const httpsPortOutput = '8080'; + const ignoreHostsOutput = '[]'; + + mockExecFile + .mockResolvedValueOnce({ stdout: modeOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpHostOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpPortOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpsHostOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpsPortOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: ignoreHostsOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8080', + no_proxy: null, + source: 'linux-system' + }); + }); + + it('should handle non-manual proxy mode', async () => { + const modeOutput = '\'auto\''; + + mockExecFile.mockResolvedValueOnce({ stdout: modeOutput, stderr: '' }); + + await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed'); + }); + + it('should handle empty ignore hosts list', async () => { + const modeOutput = '\'manual\''; + const httpHostOutput = '\'proxy.usebruno.com\''; + const httpPortOutput = '8080'; + const httpsHostOutput = '\'\''; + const httpsPortOutput = ''; + const ignoreHostsOutput = '[]'; + + mockExecFile + .mockResolvedValueOnce({ stdout: modeOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpHostOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpPortOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpsHostOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: httpsPortOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: ignoreHostsOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: null, + no_proxy: null, + source: 'linux-system' + }); + }); + }); + + describe('/etc/environment proxy detection', () => { + it('should detect proxy from /etc/environment', async () => { + // Mock gsettings to fail + mockExecFile.mockImplementation(() => { + throw new Error('gsettings not available'); + }); + + // Mock /etc/environment file + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(` +http_proxy=http://proxy.usebruno.com:8080 +https_proxy=http://proxy.usebruno.com:8080 +no_proxy=localhost,127.0.0.1 +`); + + 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', + source: 'linux-system' + }); + }); + }); + + describe('systemd proxy detection', () => { + it('should detect proxy from systemd configuration', async () => { + mockExecFile.mockImplementation(() => { + throw new Error('gsettings not available'); + }); + + // Mock all previous methods to fail + mockExistsSync.mockReturnValue(false); + + // Mock systemd conf directory to exist + mockExistsSync.mockReturnValueOnce(true); + + // Mock systemd proxy file to exist + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(` +http_proxy=http://proxy.usebruno.com:8080 +https_proxy=http://proxy.usebruno.com:8080 +no_proxy=localhost,127.0.0.1 +`); + + 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', + source: 'linux-system' + }); + }); + }); + + describe('Error Handling', () => { + it('should throw error when gsettings is not available', async () => { + const error = new Error('gsettings: command not found'); + + mockExecFile.mockImplementation(() => { + throw error; + }); + + // Mock all file-based methods to fail + mockExistsSync.mockReturnValue(false); + + await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed'); + }); + + it('should throw error when gsettings schema is not installed', async () => { + const error = new Error('No such schema'); + + mockExecFile.mockImplementation(() => { + throw error; + }); + + // Mock all file-based methods to fail + mockExistsSync.mockReturnValue(false); + + await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed'); + }); + + it('should throw error when no proxy configuration is found', async () => { + mockExecFile.mockImplementation(() => { + throw new Error('gsettings not available'); + }); + + // Mock all file-based methods to fail + mockExistsSync.mockReturnValue(false); + + await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed'); + }); + }); +}); diff --git a/packages/bruno-requests/src/network/system-proxy/utils/linux.ts b/packages/bruno-requests/src/network/system-proxy/utils/linux.ts new file mode 100644 index 000000000..deff4847e --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/linux.ts @@ -0,0 +1,227 @@ +import { ExecFileOptions } from 'node:child_process'; +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { ProxyConfiguration, ProxyResolver } from '../types'; +import { normalizeProxyUrl, normalizeNoProxy, safeExec } from './common'; + +// Pre-compile patterns for proxy variable detection +const PROXY_VAR_PATTERNS = ['http_proxy', 'https_proxy', 'no_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY'] + .flatMap((varName) => [ + { varName: varName.toLowerCase(), pattern: new RegExp(`^export\\s+${varName}\\s*=\\s*(.+)$`, 'i') }, + { varName: varName.toLowerCase(), pattern: new RegExp(`^${varName}\\s*=\\s*(.+)$`, 'i') } + ]); + +export class LinuxProxyResolver implements ProxyResolver { + async detect(opts?: { timeoutMs?: number }): Promise { + const timeoutMs = opts?.timeoutMs ?? 10000; + const execOpts: ExecFileOptions = { + timeout: timeoutMs, + maxBuffer: 1024 * 1024 + }; + + try { + // Try different proxy detection methods in order of preference + const detectionMethods = [ + () => this.getGSettingsProxy(execOpts), + () => this.getKDEProxy(execOpts), + () => this.getEnvironmentFileProxy(), + () => this.getSystemdProxy() + ]; + + for (const method of detectionMethods) { + try { + const proxy = await method(); + if (proxy) { + return proxy; + } + } catch (error) { + // Continue to next method if this one fails + continue; + } + } + + throw new Error('No Linux proxy configuration found'); + } catch (error) { + throw new Error(`Linux proxy detection failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async getGSettingsProxy(execOpts: ExecFileOptions): Promise { + try { + const mode = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'mode'], execOpts); + if (mode !== '\'manual\'') { + return null; + } + + const httpHost = await safeExec('gsettings', ['get', 'org.gnome.system.proxy.http', 'host'], execOpts); + const httpPort = await safeExec('gsettings', ['get', 'org.gnome.system.proxy.http', 'port'], execOpts); + const httpsHost = await safeExec('gsettings', ['get', 'org.gnome.system.proxy.https', 'host'], execOpts); + const httpsPort = await safeExec('gsettings', ['get', 'org.gnome.system.proxy.https', 'port'], execOpts); + const ignoreHosts = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'ignore-hosts'], execOpts); + + const cleanHttpHost = (httpHost || '').replace(/'/g, ''); + const cleanHttpPort = httpPort || ''; + const cleanHttpsHost = (httpsHost || '').replace(/'/g, ''); + const cleanHttpsPort = httpsPort || ''; + const cleanIgnoreHosts = ignoreHosts || ''; + + const http_proxy = cleanHttpHost && cleanHttpPort ? normalizeProxyUrl(`${cleanHttpHost}:${cleanHttpPort}`) : null; + const https_proxy = cleanHttpsHost && cleanHttpsPort ? normalizeProxyUrl(`${cleanHttpsHost}:${cleanHttpsPort}`, 'https') : null; + + const rawNoProxy = cleanIgnoreHosts !== '[]' ? cleanIgnoreHosts.replace(/[\[\]']/g, '').replace(/,\s*/g, ',') : null; + + return { + http_proxy, + https_proxy, + no_proxy: normalizeNoProxy(rawNoProxy), + source: 'linux-system' + }; + } catch (error) { + return null; + } + } + + private async getKDEProxy(execOpts: ExecFileOptions): Promise { + try { + // Check if kreadconfig5 is available and get proxy type + const proxyType = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'ProxyType'], execOpts); + + // ProxyType values: + // 0 = No proxy + // 1 = Manual proxy configuration + // 2 = Automatic proxy configuration via URL + // 3 = Automatic proxy detection + // 4 = Use system proxy configuration (environment variables) + + if (proxyType !== '1') { + // Only handle manual proxy configuration for now + return null; + } + + const httpProxy = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'httpProxy'], execOpts); + const httpsProxy = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'httpsProxy'], execOpts); + const noProxy = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'NoProxyFor'], execOpts); + + const cleanHttpProxy = httpProxy || ''; + const cleanHttpsProxy = httpsProxy || ''; + const cleanNoProxy = noProxy || ''; + + const http_proxy = cleanHttpProxy ? normalizeProxyUrl(cleanHttpProxy) : null; + const https_proxy = cleanHttpsProxy ? normalizeProxyUrl(cleanHttpsProxy, 'https') : null; + + return { + http_proxy, + https_proxy, + no_proxy: normalizeNoProxy(cleanNoProxy || null), + source: 'linux-system' + }; + } catch (error) { + return null; + } + } + + private async getEnvironmentFileProxy(): Promise { + try { + if (!existsSync('/etc/environment')) { + return null; + } + const content = await readFile('/etc/environment', 'utf8'); + return this.parseProxyFromContent(content); + } catch (error) { + return null; + } + } + + private async getSystemdProxy(): Promise { + try { + const systemdConfDir = '/etc/systemd/system.conf.d'; + if (!existsSync(systemdConfDir)) { + return null; + } + + // Look for systemd proxy configuration files + const files = await readdir(systemdConfDir); + const systemdFiles = files.filter((f) => f.endsWith('.conf')); + let content = ''; + + for (const file of systemdFiles) { + const filePath = `${systemdConfDir}/${file}`; + if (existsSync(filePath)) { + const fileContent = await readFile(filePath, 'utf8'); + content += fileContent + '\n'; + } + } + + if (!content) { + return null; + } + + return this.parseProxyFromContent(content); + } catch (error) { + return null; + } + } + + private parseProxyFromContent(content: string): ProxyConfiguration | null { + const proxyVars = ['http_proxy', 'https_proxy', 'no_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY']; + const proxies: Record = {}; + + const lines = content.split('\n'); + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip comments and empty lines + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue; + } + + // Handle systemd Environment= and DefaultEnvironment= directives + const systemdEnvMatch = trimmedLine.match(/^(Default)?Environment\s*=\s*(.+)$/i); + if (systemdEnvMatch) { + const envVars = systemdEnvMatch[2]; + // Parse key=value pairs from the directive (handles quoted and unquoted values) + const kvPairs = envVars.match(/([A-Z_]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/gi); + if (kvPairs) { + for (const pair of kvPairs) { + const [key, ...valueParts] = pair.split('='); + const value = valueParts.join('=').replace(/^["']|["']$/g, ''); + if (proxyVars.some((v) => v.toLowerCase() === key.toLowerCase())) { + proxies[key.toLowerCase()] = value; + } + } + } + continue; + } + + // Handle different formats: VAR=value, export VAR=value, VAR="value", etc. + for (const { varName, pattern } of PROXY_VAR_PATTERNS) { + const match = trimmedLine.match(pattern); + if (match) { + let value = match[1].trim(); + // Remove surrounding quotes + value = value.replace(/^["']|["']$/g, ''); + proxies[varName] = value; + break; + } + } + } + + // Convert to ProxyConfiguration format with ALL_PROXY fallback + const httpProxy = proxies.http_proxy || proxies.all_proxy || null; + const httpsProxy = proxies.https_proxy || proxies.all_proxy || null; + const http_proxy = httpProxy ? normalizeProxyUrl(httpProxy) : null; + const https_proxy = httpsProxy ? normalizeProxyUrl(httpsProxy, 'https') : null; + const no_proxy = proxies.no_proxy || null; + + if (http_proxy || https_proxy) { + return { + http_proxy, + https_proxy, + no_proxy: normalizeNoProxy(no_proxy), + source: 'linux-system' + }; + } + + return null; + } +} diff --git a/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts new file mode 100644 index 000000000..ea0dc25b2 --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts @@ -0,0 +1,243 @@ +import { MacOSProxyResolver } from './macos'; + +// Mock the entire child_process module +jest.mock('node:child_process', () => ({ + execFile: jest.fn() +})); + +// Mock the util module +jest.mock('node:util', () => ({ + promisify: jest.fn((fn) => fn) +})); + +describe('MacOSProxyResolver', () => { + let detector: MacOSProxyResolver; + let mockExecFile: jest.MockedFunction; + + beforeEach(() => { + detector = new MacOSProxyResolver(); + const { execFile } = require('node:child_process'); + mockExecFile = execFile; + jest.clearAllMocks(); + }); + + describe('scutil proxy detection', () => { + it('should detect HTTP and HTTPS proxy settings', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSPort : 8443 + HTTPSProxy : secure-proxy.usebruno.com + ExceptionsList : { + 0 : localhost + 1 : 127.0.0.1 + } + ExcludeSimpleHostnames : 1 +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://secure-proxy.usebruno.com:8443', + no_proxy: 'localhost,127.0.0.1,', + source: 'macos-system' + }); + }); + + it('should handle disabled proxy settings', async () => { + const scutilOutput = ` { + HTTPEnable : 0 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 0 + HTTPSProxy : proxy.usebruno.com +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: null, + https_proxy: null, + no_proxy: null, + source: 'macos-system' + }); + }); + + it('should use default ports when not specified', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSProxy : secure-proxy.usebruno.com +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result.http_proxy).toBe('http://proxy.usebruno.com:80'); + expect(result.https_proxy).toBe('https://secure-proxy.usebruno.com:443'); + }); + + it('should handle only HTTP proxy enabled', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 0 + HTTPSProxy : secure-proxy.usebruno.com +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: null, + no_proxy: null, + source: 'macos-system' + }); + }); + + it('should handle only HTTPS proxy enabled', async () => { + const scutilOutput = ` { + HTTPEnable : 0 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSPort : 8443 + HTTPSProxy : secure-proxy.usebruno.com +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: null, + https_proxy: 'https://secure-proxy.usebruno.com:8443', + no_proxy: null, + source: 'macos-system' + }); + }); + + it('should handle empty exceptions list', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSPort : 8080 + HTTPSProxy : proxy.usebruno.com + ExceptionsList : { + } + ExcludeSimpleHostnames : 0 +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8080', + no_proxy: null, + source: 'macos-system' + }); + }); + + it('should handle ExcludeSimpleHostnames without exceptions', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSPort : 8080 + HTTPSProxy : proxy.usebruno.com + ExcludeSimpleHostnames : 1 +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8080', + no_proxy: '', + source: 'macos-system' + }); + }); + + it('should handle complex exceptions list', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSPort : 8080 + HTTPSProxy : proxy.usebruno.com + ExceptionsList : { + 0 : localhost + 1 : 127.0.0.1 + 2 : *.local + 3 : 192.168.1.0/24 + } + ExcludeSimpleHostnames : 1 +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8080', + no_proxy: 'localhost,127.0.0.1,*.local,192.168.1.0/24,', + source: 'macos-system' + }); + }); + + it('should handle malformed scutil output gracefully', async () => { + const scutilOutput = ` { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : proxy.usebruno.com + HTTPSEnable : 1 + HTTPSPort : 8080 + HTTPSProxy proxy.usebruno.com +}`; + + mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: null, + no_proxy: null, + source: 'macos-system' + }); + }); + }); + + describe('Error Handling', () => { + it('should throw error when scutil command fails', async () => { + mockExecFile.mockRejectedValueOnce(new Error('scutil command not found')); + + await expect(detector.detect()).rejects.toThrow('macOS proxy detection failed'); + }); + + it('should throw error for invalid scutil output', async () => { + mockExecFile.mockResolvedValueOnce({ stdout: 'Invalid output format', stderr: '' }); + + await expect(detector.detect()).rejects.toThrow('macOS proxy detection failed'); + }); + }); +}); diff --git a/packages/bruno-requests/src/network/system-proxy/utils/macos.ts b/packages/bruno-requests/src/network/system-proxy/utils/macos.ts new file mode 100644 index 000000000..0360b84e5 --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/macos.ts @@ -0,0 +1,115 @@ +import { ExecFileOptions } from 'node:child_process'; +import { ProxyConfiguration, ProxyResolver } from '../types'; +import { normalizeProxyUrl, normalizeNoProxy, execFileAsync } from './common'; + +export class MacOSProxyResolver implements ProxyResolver { + async detect(opts?: { timeoutMs?: number }): Promise { + const timeoutMs = opts?.timeoutMs ?? 10000; + const execOpts: ExecFileOptions = { + timeout: timeoutMs, + maxBuffer: 1024 * 1024 + }; + + try { + const { stdout } = await execFileAsync('scutil', ['--proxy'], execOpts); + return this.parseScutilOutput(stdout); + } catch (error) { + throw new Error(`macOS proxy detection failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private parseScutilOutput(output: string): ProxyConfiguration { + if (typeof output !== 'string') { + throw new Error('Invalid scutil --proxy output'); + } + + const cleanLines = output.split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const dictStart = cleanLines.findIndex((line) => line.includes('')); + if (dictStart === -1) { + throw new Error('Invalid scutil --proxy output format'); + } + const config = this.parseConfiguration(cleanLines, dictStart); + return this.buildProxyConfiguration(config); + } + + private parseConfiguration(lines: string[], startIndex: number): Record { + const config: Record = {}; + let i = startIndex + 1; + + while (i < lines.length && !lines[i].includes('}')) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + const keyValueMatch = line.match(/^([^:]+)\s*:\s*(.+)$/); + if (keyValueMatch) { + const key = keyValueMatch[1].trim(); + const value = keyValueMatch[2].trim(); + + if (value === ' {') { + // Parse array + const array: string[] = []; + i++; + while (i < lines.length && !lines[i].includes('}')) { + const arrayLine = lines[i].trim(); + const arrayMatch = arrayLine.match(/^\d+\s*:\s*(.+)$/); + if (arrayMatch) { + array.push(arrayMatch[1].trim()); + } + i++; + } + config[key] = array; + } else if (value.match(/^\d+$/)) { + config[key] = parseInt(value, 10); + } else { + config[key] = value; + } + } + + i++; + } + + return config; + } + + private buildProxyConfiguration(config: Record): ProxyConfiguration { + let http_proxy: string | null = null; + let https_proxy: string | null = null; + let no_proxy: string | null = null; + + // Check HTTP proxy + if (config.HTTPEnable === 1 && config.HTTPProxy) { + const port = config.HTTPPort || 80; + http_proxy = normalizeProxyUrl(`${config.HTTPProxy}:${port}`); + } + + // Check HTTPS proxy + if (config.HTTPSEnable === 1 && config.HTTPSProxy) { + const port = config.HTTPSPort || 443; + https_proxy = normalizeProxyUrl(`${config.HTTPSProxy}:${port}`, 'https'); + } + + // Check bypass list + if (config.ExceptionsList && Array.isArray(config.ExceptionsList) && config.ExceptionsList.length > 0) { + no_proxy = config.ExceptionsList.join(','); + } + + // Handle "exclude simple hostnames" setting + if (config.ExcludeSimpleHostnames === 1) { + no_proxy = no_proxy ? `${no_proxy},` : ''; + } + + return { + http_proxy, + https_proxy, + no_proxy: normalizeNoProxy(no_proxy), + source: 'macos-system' + }; + } +} diff --git a/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts new file mode 100644 index 000000000..82c24ac88 --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts @@ -0,0 +1,400 @@ +import { WindowsProxyResolver } from './windows'; + +// Mock the entire child_process module +jest.mock('node:child_process', () => ({ + execFile: jest.fn() +})); + +// Mock the util module +jest.mock('node:util', () => ({ + promisify: jest.fn((fn) => fn) +})); + +describe('WindowsProxyResolver', () => { + let detector: WindowsProxyResolver; + let mockExecFile: jest.MockedFunction; + + beforeEach(() => { + detector = new WindowsProxyResolver(); + const { execFile } = require('node:child_process'); + mockExecFile = execFile; + jest.clearAllMocks(); + }); + + describe('Internet Options Registry Detection', () => { + it('should detect single proxy configuration', 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 +`; + + 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', + source: 'windows-system' + }); + }); + + it('should detect protocol-specific proxy configuration', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x1 + ProxyServer REG_SZ http=proxy.usebruno.com:8080;https=proxy.usebruno.com:8443 +`; + + mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8443', + no_proxy: null, + source: 'windows-system' + }); + }); + + it('should fallback to WinHTTP when registry fails', async () => { + const winhttpOutput = ` +Current WinHTTP proxy settings: + Proxy Server(s) : proxy.usebruno.com:8080 + Bypass List : localhost +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Registry access denied')) + .mockResolvedValueOnce({ stdout: winhttpOutput, 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', + source: 'windows-system' + }); + }); + }); + + describe('WinHTTP Detection', () => { + it('should handle direct access configuration', async () => { + mockExecFile + .mockRejectedValueOnce(new Error('Registry not accessible')) + .mockResolvedValueOnce({ stdout: 'Direct access (no proxy server)', stderr: '' }); + + await expect(detector.detect()).rejects.toThrow('Windows proxy detection failed'); + }); + + it('should detect single proxy from WinHTTP', async () => { + const winhttpOutput = ` +Current WinHTTP proxy settings: + Proxy Server(s) : proxy.usebruno.com:8080 + Bypass List : localhost;127.0.0.1 +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Registry access denied')) + .mockResolvedValueOnce({ stdout: winhttpOutput, 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', + source: 'windows-system' + }); + }); + + it('should detect protocol-specific proxy from WinHTTP', async () => { + const winhttpOutput = ` +Current WinHTTP proxy settings: + Proxy Server(s) : http=proxy.usebruno.com:8080;https=proxy.usebruno.com:8443 + Bypass List : localhost +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Registry access denied')) + .mockResolvedValueOnce({ stdout: winhttpOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://proxy.usebruno.com:8443', + no_proxy: 'localhost', + source: 'windows-system' + }); + }); + + it('should handle WinHTTP with no bypass list', async () => { + const winhttpOutput = ` +Current WinHTTP proxy settings: + Proxy Server(s) : proxy.usebruno.com:8080 + Bypass List : (none) +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Registry access denied')) + .mockResolvedValueOnce({ stdout: winhttpOutput, 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: null, + source: 'windows-system' + }); + }); + }); + + describe('System Proxy Environment Detection', () => { + it('should detect system-wide proxy environment variables', async () => { + const regOutput = ` +HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment + HTTP_PROXY REG_SZ http://proxy.usebruno.com:8080 + HTTPS_PROXY REG_SZ http://proxy.usebruno.com:8080 + NO_PROXY REG_SZ localhost,127.0.0.1 +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Internet Options not accessible')) + .mockRejectedValueOnce(new Error('WinHTTP not accessible')) + .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', + source: 'windows-system' + }); + }); + + it('should handle only HTTP_PROXY in system environment', async () => { + const regOutput = ` +HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment + HTTP_PROXY REG_SZ http://proxy.usebruno.com:8080 +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Internet Options not accessible')) + .mockRejectedValueOnce(new Error('WinHTTP not accessible')) + .mockResolvedValueOnce({ stdout: regOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: null, + no_proxy: null, + source: 'windows-system' + }); + }); + }); + + describe('User Environment Proxy Detection', () => { + it('should detect proxy from HKCU\\Environment', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Environment + HTTP_PROXY REG_SZ http://proxy.usebruno.com:8080 + HTTPS_PROXY REG_SZ http://proxy.usebruno.com:8080 + NO_PROXY REG_SZ localhost,127.0.0.1 +`; + + mockExecFile + .mockRejectedValueOnce(new Error('Internet Options not accessible')) + .mockRejectedValueOnce(new Error('WinHTTP not accessible')) + .mockRejectedValueOnce(new Error('System environment not accessible')) + .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', + source: 'windows-system' + }); + }); + }); + + describe('Edge Cases and Parsing', () => { + it('should handle proxy server with existing http:// prefix', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x1 + ProxyServer REG_SZ http://proxy.usebruno.com:8080 + ProxyOverride REG_SZ localhost;127.0.0.1 +`; + + 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', + source: 'windows-system' + }); + }); + + it('should handle protocol-specific proxies with existing prefixes', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x1 + ProxyServer REG_SZ http=http://proxy.usebruno.com:8080;https=https://secure-proxy.usebruno.com:8443 +`; + + mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' }); + + const result = await detector.detect(); + + expect(result).toEqual({ + http_proxy: 'http://proxy.usebruno.com:8080', + https_proxy: 'https://secure-proxy.usebruno.com:8443', + no_proxy: null, + source: 'windows-system' + }); + }); + + it('should handle empty proxy override', 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 +`; + + 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: null, + source: 'windows-system' + }); + }); + + it('should handle proxy disabled in registry', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0x0 + ProxyServer REG_SZ proxy.usebruno.com:8080 + ProxyOverride REG_SZ localhost;127.0.0.1 +`; + + mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' }); + + await expect(detector.detect()).rejects.toThrow('Windows proxy detection failed'); + }); + + it('should handle decimal ProxyEnable value', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 1 + ProxyServer REG_SZ proxy.usebruno.com:8080 + ProxyOverride REG_SZ localhost;127.0.0.1 +`; + + 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', + source: 'windows-system' + }); + }); + + it('should handle decimal ProxyEnable disabled', async () => { + const regOutput = ` +HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings + ProxyEnable REG_DWORD 0 + ProxyServer REG_SZ proxy.usebruno.com:8080 + ProxyOverride REG_SZ localhost;127.0.0.1 +`; + + mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' }); + + await expect(detector.detect()).rejects.toThrow('Windows proxy detection failed'); + }); + + it('should handle malformed registry output gracefully', 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 + SomeOtherValue REG_SZ ignored +`; + + 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', + source: 'windows-system' + }); + }); + + it('should handle complex bypass list', 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;*.local;192.168.1.0/24 +`; + + 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,*.local,192.168.1.0/24', + source: 'windows-system' + }); + }); + }); + + describe('Error Handling', () => { + it('should throw error when no proxy configuration is found', async () => { + mockExecFile.mockRejectedValue(new Error('Command failed')); + + await expect(detector.detect()).rejects.toThrow('Windows proxy detection failed'); + }); + + it('should handle registry access denied gracefully', async () => { + mockExecFile.mockRejectedValue(new Error('Access is denied')); + + await expect(detector.detect()).rejects.toThrow('Windows proxy detection failed'); + }); + + it('should handle malformed WinHTTP output', async () => { + mockExecFile + .mockRejectedValueOnce(new Error('Registry not accessible')) + .mockResolvedValueOnce({ stdout: 'Malformed WinHTTP output', stderr: '' }); + + await expect(detector.detect()).rejects.toThrow('Windows proxy detection failed'); + }); + }); +}); diff --git a/packages/bruno-requests/src/network/system-proxy/utils/windows.ts b/packages/bruno-requests/src/network/system-proxy/utils/windows.ts new file mode 100644 index 000000000..e0e6ff5cf --- /dev/null +++ b/packages/bruno-requests/src/network/system-proxy/utils/windows.ts @@ -0,0 +1,213 @@ +import { ExecFileOptions } from 'node:child_process'; +import { ProxyConfiguration, ProxyResolver } from '../types'; +import { normalizeProxyUrl, normalizeNoProxy, safeExec } from './common'; + +export class WindowsProxyResolver implements ProxyResolver { + async detect(opts?: { timeoutMs?: number }): Promise { + const timeoutMs = opts?.timeoutMs ?? 10000; + const execOpts: ExecFileOptions = { + timeout: timeoutMs, + windowsHide: true, + maxBuffer: 1024 * 1024 + }; + + try { + // Try different detection methods in order of preference + const detectionMethods = [ + () => this.getInternetOptions(execOpts), + () => this.getWinHttpProxy(execOpts), + () => this.getSystemProxyEnvironment(execOpts), + () => this.getUserEnvironmentProxy(execOpts) + ]; + + for (const method of detectionMethods) { + try { + const proxy = await method(); + if (proxy) { + return proxy; + } + } catch (error) { + // Continue to next method if this one fails + continue; + } + } + + throw new Error('No Windows proxy configuration found'); + } catch (error) { + throw new Error(`Windows proxy detection failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async getInternetOptions(execOpts: ExecFileOptions): Promise { + const stdout = await safeExec('reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'], execOpts); + if (!stdout) return null; + + const lines = stdout.split('\n'); + let proxyEnabled = false; + let proxyServer: string | null = null; + let proxyOverride: string | null = null; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.includes('ProxyEnable') && trimmedLine.includes('REG_DWORD')) { + // Extract the value after REG_DWORD + const match = trimmedLine.match(/ProxyEnable\s+REG_DWORD\s+(0x[0-9a-fA-F]+|\d+)/); + if (match) { + const value = match[1]; + proxyEnabled = value === '0x1' || value === '1'; + } + } + + if (trimmedLine.includes('ProxyServer') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/ProxyServer\s+REG_SZ\s+(.+)/); + if (match) proxyServer = match[1].trim(); + } + + if (trimmedLine.includes('ProxyOverride') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/ProxyOverride\s+REG_SZ\s+(.+)/); + if (match) proxyOverride = match[1].trim(); + } + } + + if (proxyEnabled && proxyServer) { + return this.parseProxyString(proxyServer, proxyOverride); + } + + return null; + } + + private async getWinHttpProxy(execOpts: ExecFileOptions): Promise { + const stdout = await safeExec('netsh', ['winhttp', 'show', 'proxy'], execOpts); + if (!stdout) return null; + + if (stdout.includes('Direct access (no proxy server)')) { + return null; + } + + const proxyServerMatch = stdout.match(/Proxy Server\(s\)\s*:\s*(.+)/); + const bypassListMatch = stdout.match(/Bypass List\s*:\s*(.+)/); + + if (proxyServerMatch) { + const proxyServer = proxyServerMatch[1].trim(); + const bypassList = bypassListMatch ? bypassListMatch[1].trim() : ''; + + return this.parseProxyString(proxyServer, bypassList); + } + + return null; + } + + private async getSystemProxyEnvironment(execOpts: ExecFileOptions): Promise { + // Check for system-wide proxy environment variables + const stdout = await safeExec('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'], execOpts); + if (!stdout) return null; + + const lines = stdout.split('\n'); + let http_proxy: string | null = null; + let https_proxy: string | null = null; + let no_proxy: string | null = null; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.toUpperCase().includes('HTTP_PROXY') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/HTTP_PROXY\s+REG_SZ\s+(.+)/i); + if (match) http_proxy = match[1].trim(); + } + + if (trimmedLine.toUpperCase().includes('HTTPS_PROXY') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/HTTPS_PROXY\s+REG_SZ\s+(.+)/i); + if (match) https_proxy = match[1].trim(); + } + + if (trimmedLine.toUpperCase().includes('NO_PROXY') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/NO_PROXY\s+REG_SZ\s+(.+)/i); + if (match) no_proxy = match[1].trim(); + } + } + + if (http_proxy || https_proxy) { + return { + http_proxy: http_proxy ? normalizeProxyUrl(http_proxy) : null, + https_proxy: https_proxy ? normalizeProxyUrl(https_proxy, 'https') : null, + no_proxy: no_proxy ? normalizeNoProxy(no_proxy) : null, + source: 'windows-system' + }; + } + + return null; + } + + private async getUserEnvironmentProxy(execOpts: ExecFileOptions): Promise { + // Check for user-specific proxy environment variables in HKCU\Environment + const stdout = await safeExec('reg', ['query', 'HKCU\\Environment'], execOpts); + if (!stdout) return null; + + const lines = stdout.split('\n'); + let http_proxy: string | null = null; + let https_proxy: string | null = null; + let no_proxy: string | null = null; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.toUpperCase().includes('HTTP_PROXY') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/HTTP_PROXY\s+REG_SZ\s+(.+)/i); + if (match) http_proxy = match[1].trim(); + } + + if (trimmedLine.toUpperCase().includes('HTTPS_PROXY') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/HTTPS_PROXY\s+REG_SZ\s+(.+)/i); + if (match) https_proxy = match[1].trim(); + } + + if (trimmedLine.toUpperCase().includes('NO_PROXY') && trimmedLine.includes('REG_SZ')) { + const match = trimmedLine.match(/NO_PROXY\s+REG_SZ\s+(.+)/i); + if (match) no_proxy = match[1].trim(); + } + } + + if (http_proxy || https_proxy) { + return { + http_proxy: http_proxy ? normalizeProxyUrl(http_proxy) : null, + https_proxy: https_proxy ? normalizeProxyUrl(https_proxy, 'https') : null, + no_proxy: no_proxy ? normalizeNoProxy(no_proxy) : null, + source: 'windows-system' + }; + } + + return null; + } + + private parseProxyString(proxyServer: string, bypassList: string | null): ProxyConfiguration { + let http_proxy: string | null = null; + let https_proxy: string | null = null; + + if (proxyServer.includes('=')) { + // Protocol-specific format: "http=proxy1:8080;https=proxy2:8080" + const protocols = proxyServer.split(';'); + for (const protocol of protocols) { + const [proto, server] = protocol.split('='); + if (!server || !proto) continue; + if (proto === 'http') { + http_proxy = normalizeProxyUrl(server); + } else if (proto === 'https') { + https_proxy = normalizeProxyUrl(server, 'https'); + } + } + } else { + // Single proxy for all protocols: "proxy.example.com:8080" + const proxy = normalizeProxyUrl(proxyServer); + http_proxy = proxy; + https_proxy = proxy; + } + + return { + http_proxy, + https_proxy, + no_proxy: bypassList && bypassList !== '(none)' ? normalizeNoProxy(bypassList) : null, + source: 'windows-system' + }; + } +}