Skip to content

Commit

Permalink
Added logic to auto detect system level proxy (#1665)
Browse files Browse the repository at this point in the history
* Added logic to auto detect system level proxy

* Fix import

* Added tests + some refactoring

* Fix coverage

* Fix coverage

* Add test for coverage

* break into smaller functions

* Adressed comments
  • Loading branch information
chinmay-browserstack authored Jul 24, 2024
1 parent e6b3ec4 commit ed3868d
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./detect-proxy": "./dist/detect-proxy.js",
"./test/helpers": "./test/helpers.js"
},
"scripts": {
Expand Down
104 changes: 104 additions & 0 deletions packages/client/src/detect-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import logger from '@percy/logger';

export default class DetectProxy {
constructor() {
this.execPromise = promisify(exec);
this.platform = process.platform;
// There are sock proxies as well which we don't need
this.filter = ['HTTP', 'HTTPS'];
}

async getSystemProxy() {
if (this.platform === 'darwin') {
return await this.getProxyFromMac();
} else if (this.platform === 'win32') {
return await this.getProxyFromWindows();
} else if (this.platform !== 'linux') {
logger('client:detect-proxy').debug(`Not able to auto detect system proxy for ${this.platform} platform`);
}
return [];
}

async getProxyFromMac() {
// Sample output
/*
HTTPEnable : 1
HTTPProxy : proxy.example.com
HTTPPort : 8080
HTTPSEnable : 1
HTTPSProxy : secureproxy.example.com
HTTPSPort : 8443
*/
const { stdout } = await this.execPromise('scutil --proxy');
const dictionary = {};
const lines = stdout.split('\n');
lines.forEach(line => {
let [key, value] = line.split(' : ');
if (key && value) {
key = key.trim();
value = value.trim();
if (key.endsWith('Enable')) {
dictionary[key] = value === '1';
} else if (key.endsWith('Port')) {
dictionary[key] = parseInt(value);
} else {
dictionary[key] = value;
}
}
});
const proxies = [];
for (const type of this.filter) {
if (dictionary[`${type}Enable`] && dictionary[`${type}Proxy`] && dictionary[`${type}Port`]) {
proxies.push({ type: type, host: dictionary[`${type}Proxy`], port: dictionary[`${type}Port`] });
}
}
return proxies;
}

async getProxyFromWindows() {
// Sample output
/*
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
User Agent REG_SZ Mozilla/4.0 (compatible; MSIE 8.0; Win32)
IE5_UA_Backup_Flag REG_SZ 5.0
ZonesSecurityUpgrade REG_BINARY ABCD
EmailName REG_SZ User@
AutoConfigProxy REG_SZ wininet.dll
MimeExclusionListForCache REG_SZ multipart/mixed multipart/x-mixed-replace multipart/x-byteranges
WarnOnPost REG_BINARY 01000000
UseSchannelDirectly REG_BINARY 01000000
EnableHttp1_1 REG_DWORD 0x1
UrlEncoding REG_DWORD 0x0
SecureProtocols REG_DWORD 0xa0
PrivacyAdvanced REG_DWORD 0x0
DisableCachingOfSSLPages REG_DWORD 0x1
WarnonZoneCrossing REG_DWORD 0x1
CertificateRevocation REG_DWORD 0x1
EnableNegotiate REG_DWORD 0x1
MigrateProxy REG_DWORD 0x1
ProxyEnable REG_DWORD 0x0
*/
const { stdout } = await this.execPromise(
'reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"'
);
const lines = stdout.split('\n');
const dictionary = {};
lines.forEach(line => {
const [key, type, value] = line.trim().split(/\s+/);
if (key && type && value) {
if (type === 'REG_DWORD') {
dictionary[key] = value === '0x1';
} else if (type === 'REG_SZ') {
dictionary[key] = value;
}
}
});
if (this.filter.includes('HTTP') && dictionary.ProxyEnable && dictionary.ProxyServer) {
const [host, port] = dictionary.ProxyServer.split(':');
return [{ type: 'HTTP', host, port: parseInt(port) }];
}
return [];
}
}
133 changes: 133 additions & 0 deletions packages/client/test/unit/detect-proxy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@

import DetectProxy from '../../src/detect-proxy.js';
import logger from '@percy/logger/test/helpers';

const detectProxy = new DetectProxy();

describe('getSystemProxy', () => {
let mockExecPromise;

beforeAll(async () => {
mockExecPromise = spyOn(detectProxy, 'execPromise');
await logger.mock({ level: 'debug' });
});

describe('on macOS', () => {
beforeAll(() => {
detectProxy.platform = 'darwin';
});

it('should return proxies if they are enabled and present', async () => {
const mockOutput = `
HTTPEnable : 1
HTTPProxy : proxy.example.com
HTTPPort : 8080
HTTPSEnable : 1
HTTPSProxy : secureproxy.example.com
HTTPSPort : 8443
`;
mockExecPromise.and.returnValue(Promise.resolve({ stdout: mockOutput }));

const proxies = await detectProxy.getSystemProxy();
expect(proxies).toEqual([
{ type: 'HTTP', host: 'proxy.example.com', port: 8080 },
{ type: 'HTTPS', host: 'secureproxy.example.com', port: 8443 }
]);
});

it('should return an empty array if proxies are not enabled', async () => {
const mockOutput = `
HTTPEnable : 0
HTTPProxy : proxy.example.com
HTTPPort : 8080
`;
mockExecPromise.and.returnValue(Promise.resolve({ stdout: mockOutput }));

const proxies = await detectProxy.getSystemProxy();
expect(proxies).toEqual([]);
});

it('should return an empty array empty response', async () => {
const mockOutput = '';
mockExecPromise.and.returnValue(Promise.resolve({ stdout: mockOutput }));

const proxies = await detectProxy.getSystemProxy();
expect(proxies).toEqual([]);
});
});

describe('on Windows', () => {
beforeAll(() => {
detectProxy.platform = 'win32';
});

it('should return proxy if it is enabled and present', async () => {
const mockOutput = `
ProxyEnable REG_DWORD 0x1
ProxyServer REG_SZ proxy.example.com:8080
`;
mockExecPromise.and.returnValue(Promise.resolve({ stdout: mockOutput }));

const proxy = await detectProxy.getSystemProxy();
expect(proxy).toEqual([{
type: 'HTTP',
host: 'proxy.example.com',
port: 8080
}]);
});

it('should return undefined if proxy is not enabled', async () => {
const mockOutput = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
User Agent REG_SZ Mozilla/4.0 (compatible; MSIE 8.0; Win32)
IE5_UA_Backup_Flag REG_SZ 5.0
ZonesSecurityUpgrade REG_BINARY ABCD
EmailName REG_SZ User@
AutoConfigProxy REG_SZ wininet.dll
MimeExclusionListForCache REG_SZ multipart/mixed multipart/x-mixed-replace multipart/x-byteranges
WarnOnPost REG_BINARY 01000000
UseSchannelDirectly REG_BINARY 01000000
EnableHttp1_1 REG_DWORD 0x1
UrlEncoding REG_DWORD 0x0
SecureProtocols REG_DWORD 0xa0
PrivacyAdvanced REG_DWORD 0x0
DisableCachingOfSSLPages REG_DWORD 0x1
WarnonZoneCrossing REG_DWORD 0x1
CertificateRevocation REG_DWORD 0x1
EnableNegotiate REG_DWORD 0x1
MigrateProxy REG_DWORD 0x1
ProxyEnable REG_DWORD 0x0
`;
mockExecPromise.and.returnValue(Promise.resolve({ stdout: mockOutput }));

const proxy = await detectProxy.getSystemProxy();
expect(proxy).toEqual([]);
});
});

describe('on linux platforms', () => {
beforeAll(() => {
detectProxy.platform = 'linux';
});

it('should log a debug message and return empty array', async () => {
const proxy = await detectProxy.getSystemProxy();
expect(proxy).toEqual([]);
expect(logger.stderr).toEqual([]);
});
});

describe('on unsupported platforms', () => {
beforeAll(() => {
detectProxy.platform = 'aix';
});

it('should log a debug message and return empty array', async () => {
const proxy = await detectProxy.getSystemProxy();
expect(proxy).toEqual([]);
expect(logger.stderr).toEqual([
'[percy:client:detect-proxy] Not able to auto detect system proxy for aix platform'
]);
});
});
});
4 changes: 4 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export const configSchema = {
deferUploads: {
type: 'boolean'
},
useSystemProxy: {
type: 'boolean',
default: false
},
token: {
type: 'string'
},
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/percy.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
generatePromise,
yieldAll,
yieldTo
, redactSecrets
, redactSecrets,
detectSystemProxyAndLog
} from './utils.js';

import {
Expand Down Expand Up @@ -163,6 +164,9 @@ export class Percy {
if (process.env.PERCY_CLIENT_ERROR_LOGS !== 'false') {
this.log.warn('Notice: Percy collects CI logs for service improvement, stored for 30 days. Opt-out anytime with export PERCY_CLIENT_ERROR_LOGS=false');
}
// Not awaiting proxy check as this can be asyncronous when not enabled
const detectProxy = detectSystemProxyAndLog(this.config.percy.useSystemProxy);
if (this.config.percy.useSystemProxy) await detectProxy;
// start the snapshots queue immediately when not delayed or deferred
if (!this.delayUploads && !this.deferUploads) yield this.#snapshots.start();
// do not start the discovery queue when not needed
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import YAML from 'yaml';
import path from 'path';
import url from 'url';
import { readFileSync } from 'fs';
import logger from '@percy/logger';
import DetectProxy from '@percy/client/detect-proxy';

export {
request,
Expand Down Expand Up @@ -380,6 +382,36 @@ export function snapshotLogName(name, meta) {
return name;
}

export async function detectSystemProxyAndLog(applyProxy) {
// if proxy is already set no need to check again
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) return;

let proxyPresent = false;
const log = logger('core:utils');
// Checking proxy shouldn't cause failure
try {
const detectProxy = new DetectProxy();
const proxies = await detectProxy.getSystemProxy();
proxyPresent = proxies.length !== 0;
if (proxyPresent) {
if (applyProxy) {
proxies.forEach((proxy) => {
if (proxy.type === 'HTTPS') {
process.env.HTTPS_PROXY = 'https://' + proxy.host + ':' + proxy.port;
} else if (proxy.type === 'HTTP') {
process.env.HTTP_PROXY = 'http://' + proxy.host + ':' + proxy.port;
}
});
} else {
log.warn('We have detected a system level proxy in your system. use HTTP_PROXY or HTTPS_PROXY env vars or To auto apply proxy set useSystemProxy: true under percy in config file');
}
}
} catch (e) {
log.debug(`Failed to detect system proxy ${e}`);
}
return proxyPresent;
}

// DefaultMap, which returns a default value for an uninitialized key
// Similar to defaultDict in python
export class DefaultMap extends Map {
Expand Down
55 changes: 55 additions & 0 deletions packages/core/test/percy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logger, api, setupTest, createTestServer } from './helpers/index.js';
import { generatePromise, AbortController, base64encode } from '../src/utils.js';
import Percy from '@percy/core';
import Pako from 'pako';
import DetectProxy from '@percy/client/detect-proxy';

describe('Percy', () => {
let percy, server;
Expand Down Expand Up @@ -526,6 +527,60 @@ describe('Percy', () => {
url: 'http://localhost:8000'
})).toThrowError('Build has failed');
});

it('skips system check if proxy already present', async () => {
process.env.HTTP_PROXY = 'some-proxy';
const mockDetectProxy = spyOn(DetectProxy.prototype, 'getSystemProxy').and.returnValue([{ type: 'HTTP', host: 'proxy.example.com', port: 8080 }]);
await expectAsync(percy.start()).toBeResolved();

expect(mockDetectProxy).not.toHaveBeenCalled();
expect(logger.stdout).toEqual([
'[percy] Percy has started!'
]);
delete process.env.HTTP_PROXY;
});

it('takes no action when no proxt is detected', async () => {
spyOn(DetectProxy.prototype, 'getSystemProxy').and.returnValue([]);
await expectAsync(percy.start()).toBeResolved();

expect(logger.stdout).toEqual([
'[percy] Percy has started!'
]);
});

it('checks for system level proxy and print warning', async () => {
spyOn(DetectProxy.prototype, 'getSystemProxy').and.returnValue([{ type: 'HTTP', host: 'proxy.example.com', port: 8080 }]);
await expectAsync(percy.start()).toBeResolved();

expect(logger.stderr).toEqual([
'[percy] We have detected a system level proxy in your system. use HTTP_PROXY or HTTPS_PROXY env vars or To auto apply proxy set useSystemProxy: true under percy in config file'
]);
expect(logger.stdout).toEqual([
'[percy] Percy has started!'
]);
});

it('checks for system level proxy and auto apply', async () => {
spyOn(DetectProxy.prototype, 'getSystemProxy').and.returnValue([
{ type: 'HTTP', host: 'proxy.example.com', port: 8080 },
{ type: 'HTTPS', host: 'secureproxy.example.com', port: 8443 },
{ type: 'SOCK', host: 'sockproxy.example.com', port: 8081 }
]);

percy = new Percy({ token: 'PERCY_TOKEN', percy: { useSystemProxy: true } });
await percy.start();

expect(process.env.HTTPS_PROXY).toEqual('https://secureproxy.example.com:8443');
expect(process.env.HTTP_PROXY).toEqual('http://proxy.example.com:8080');
delete process.env.HTTPS_PROXY;
delete process.env.HTTP_PROXY;
});

it('should not cause error when failed to detect system level proxy', async () => {
spyOn(DetectProxy.prototype, 'getSystemProxy').and.rejectWith('some error');
await expectAsync(percy.start()).toBeResolved();
});
});

describe('#stop()', () => {
Expand Down

0 comments on commit ed3868d

Please sign in to comment.