mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
fix: system proxy resolver updates (#6273)
This commit is contained in:
@@ -1 +1,3 @@
|
||||
export { makeAxiosInstance } from './axios-instance';
|
||||
|
||||
export { getSystemProxy } from './system-proxy';
|
||||
|
||||
270
packages/bruno-requests/src/network/system-proxy/index.spec.js
Normal file
270
packages/bruno-requests/src/network/system-proxy/index.spec.js
Normal file
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
107
packages/bruno-requests/src/network/system-proxy/index.ts
Normal file
107
packages/bruno-requests/src/network/system-proxy/index.ts
Normal file
@@ -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<ProxyConfiguration> | null = null;
|
||||
|
||||
constructor(options: SystemProxyResolverOptions = {}) {
|
||||
this.osPlatform = platform();
|
||||
if (options.commandTimeoutMs) {
|
||||
this.commandTimeoutMs = options.commandTimeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemProxy(): Promise<ProxyConfiguration> {
|
||||
// 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<ProxyConfiguration> {
|
||||
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<ProxyConfiguration> {
|
||||
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<ProxyConfiguration> {
|
||||
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';
|
||||
14
packages/bruno-requests/src/network/system-proxy/types.ts
Normal file
14
packages/bruno-requests/src/network/system-proxy/types.ts
Normal file
@@ -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<ProxyConfiguration>;
|
||||
}
|
||||
|
||||
export interface SystemProxyResolverOptions {
|
||||
commandTimeoutMs?: number;
|
||||
}
|
||||
@@ -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;<local>')).toBe('localhost,127.0.0.1,*.local,192.168.1.0/24,<local>');
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
};
|
||||
@@ -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<any>;
|
||||
let mockReadFile: jest.MockedFunction<any>;
|
||||
let mockExistsSync: jest.MockedFunction<any>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
227
packages/bruno-requests/src/network/system-proxy/utils/linux.ts
Normal file
227
packages/bruno-requests/src/network/system-proxy/utils/linux.ts
Normal file
@@ -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<ProxyConfiguration> {
|
||||
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<ProxyConfiguration | null> {
|
||||
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<ProxyConfiguration | null> {
|
||||
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<ProxyConfiguration | null> {
|
||||
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<ProxyConfiguration | null> {
|
||||
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<string, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<any>;
|
||||
|
||||
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 = `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 8080
|
||||
HTTPProxy : proxy.usebruno.com
|
||||
HTTPSEnable : 1
|
||||
HTTPSPort : 8443
|
||||
HTTPSProxy : secure-proxy.usebruno.com
|
||||
ExceptionsList : <array> {
|
||||
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,<local>',
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle disabled proxy settings', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
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 = `<dictionary> {
|
||||
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 = `<dictionary> {
|
||||
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 = `<dictionary> {
|
||||
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 = `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 8080
|
||||
HTTPProxy : proxy.usebruno.com
|
||||
HTTPSEnable : 1
|
||||
HTTPSPort : 8080
|
||||
HTTPSProxy : proxy.usebruno.com
|
||||
ExceptionsList : <array> {
|
||||
}
|
||||
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 = `<dictionary> {
|
||||
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: '<local>',
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex exceptions list', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 8080
|
||||
HTTPProxy : proxy.usebruno.com
|
||||
HTTPSEnable : 1
|
||||
HTTPSPort : 8080
|
||||
HTTPSProxy : proxy.usebruno.com
|
||||
ExceptionsList : <array> {
|
||||
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,<local>',
|
||||
source: 'macos-system'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle malformed scutil output gracefully', async () => {
|
||||
const scutilOutput = `<dictionary> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/bruno-requests/src/network/system-proxy/utils/macos.ts
Normal file
115
packages/bruno-requests/src/network/system-proxy/utils/macos.ts
Normal file
@@ -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<ProxyConfiguration> {
|
||||
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('<dictionary>'));
|
||||
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<string, any> {
|
||||
const config: Record<string, any> = {};
|
||||
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 === '<array> {') {
|
||||
// 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<string, any>): 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},<local>` : '<local>';
|
||||
}
|
||||
|
||||
return {
|
||||
http_proxy,
|
||||
https_proxy,
|
||||
no_proxy: normalizeNoProxy(no_proxy),
|
||||
source: 'macos-system'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<any>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ProxyConfiguration> {
|
||||
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<ProxyConfiguration | null> {
|
||||
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<ProxyConfiguration | null> {
|
||||
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<ProxyConfiguration | null> {
|
||||
// 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<ProxyConfiguration | null> {
|
||||
// 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user