Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Change Log
Notable changes will be documented here.

## [0.43.0]
- Add `resolveProxyByURL` to `createProxyResolver`, returning the resolved proxy `url`, `type` (`DIRECT`/`PROXY`/`HTTP`/`HTTPS`/`SOCKS`/`SOCKS5`/`SOCKS4`/`EMPTY`/`UNRECOGNIZED`) and `source` (`localhost`/`noProxyConfig`/`noProxyEnv`/`setting`/`env`/`remote`/`system_cached`/`system`/`fallback`). `getProxyURLFromResolverResult` now also returns the resolved `type`. The existing `resolveProxyURL` is unchanged.

## [0.42.0]
- Add `interceptors` option to `createFetchPatch` for composing additional undici interceptors (e.g. RFC 9111 cache) on the patched `fetch` at construction time ([microsoft/vscode-proxy-agent#100](https://github.com/microsoft/vscode-proxy-agent/pull/100))

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vscode/proxy-agent",
"version": "0.42.0",
"version": "0.43.0",
"description": "NodeJS http(s) agent implementation for VS Code",
"main": "out/index.js",
"types": "out/index.d.ts",
Expand Down
26 changes: 19 additions & 7 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,20 @@ export function sanitizeProxyResultCredentials(result: string | undefined): stri
return String(result).replace(/(\b(?:PROXY|HTTPS?|SOCKS[45]?)\s+)[^\s@]+@/gi, '$1<credentials>@');
}

export function getProxyURLFromResolverResult(result: string | undefined) {
/**
* The kind of proxy that was resolved for a URL, as reported by the PAC-style
* resolver result (`DIRECT`, `PROXY`, `HTTP`, `HTTPS`, `SOCKS`, `SOCKS5`,
* `SOCKS4`). Note `HTTP` and `PROXY` both denote an HTTP proxy, and `SOCKS`
* denotes SOCKSv5. `EMPTY` means no result was returned at all (falsy) and
* `UNRECOGNIZED` means a non-empty result was returned but none of its entries
* used a known scheme; both are treated as a direct connection.
*/
export type ProxyResolveType = 'DIRECT' | 'PROXY' | 'HTTP' | 'HTTPS' | 'SOCKS' | 'SOCKS5' | 'SOCKS4' | 'EMPTY' | 'UNRECOGNIZED';

export function getProxyURLFromResolverResult(result: string | undefined): { proxy: string; url: string | undefined; type: ProxyResolveType } {
// Default to "DIRECT" if a falsey value was returned (or nothing)
if (!result) {
return { proxy: 'DIRECT', url: undefined };
return { proxy: 'DIRECT', url: undefined, type: 'EMPTY' };
}

const proxies = String(result)
Expand All @@ -159,24 +169,26 @@ export function getProxyURLFromResolverResult(result: string | undefined) {
debug('Attempting to use proxy: %o', proxy);

if (type === 'DIRECT') {
return { proxy, url: undefined };
return { proxy, url: undefined, type };
} else if (type === 'SOCKS' || type === 'SOCKS5') {
// Use a SOCKSv5h proxy
return { proxy, url: `socks://${target}` };
return { proxy, url: `socks://${target}`, type };
} else if (type === 'SOCKS4') {
// Use a SOCKSv4a proxy
return { proxy, url: `socks4a://${target}` };
return { proxy, url: `socks4a://${target}`, type };
} else if (
type === 'PROXY' ||
type === 'HTTP' ||
type === 'HTTPS'
) {
// Use an HTTP or HTTPS proxy
// http://dev.chromium.org/developers/design-documents/secure-web-proxy
return { proxy, url: `${type === 'HTTPS' ? 'https' : 'http'}://${target}` };
return { proxy, url: `${type === 'HTTPS' ? 'https' : 'http'}://${target}`, type };
}
}
return { proxy: 'DIRECT', url: undefined };
// A non-empty result was returned but no entry used a known scheme; fall back
// to a direct connection while reporting the result as unrecognized.
return { proxy: 'DIRECT', url: undefined, type: 'UNRECOGNIZED' };
}

type LookupProxyAuthorization = (proxyURL: string, proxyAuthenticate: string | string[] | undefined, state: Record<string, any>) => Promise<string | undefined>;
Expand Down
61 changes: 50 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import * as crypto from 'crypto';
import * as undici from 'undici';
import * as stream from 'stream';

import { createPacProxyAgent, getProxyURLFromResolverResult, PacProxyAgent } from './agent';
import { createPacProxyAgent, getProxyURLFromResolverResult, PacProxyAgent, ProxyResolveType } from './agent';
import type { IncomingHttpHeaders } from 'undici/types/header';

export enum LogLevel {
Expand Down Expand Up @@ -82,6 +82,35 @@ export interface ProxyAgentParams {
env: NodeJS.ProcessEnv;
}

export type { ProxyResolveType } from './agent';

/**
* Which configuration determined the resolved proxy. Mirrors the resolution
* order in `useProxySettings`:
* - `localhost`: the target is a loopback host (always direct).
* - `noProxyConfig`: excluded by the `http.noProxy` setting.
* - `noProxyEnv`: excluded by the `no_proxy`/`NO_PROXY` environment variable.
* - `setting`: the `http.proxy` setting (via `getProxyURL`).
* - `env`: the `http(s)_proxy` environment variable.
* - `remote`: host proxy resolution is disabled (`isUseHostProxyEnabled` is false), e.g. in remote scenarios.
* - `system_cached`: served from the in-memory cache of a previous system resolution.
* - `system`: resolved via the operating system / PAC (`resolveProxy`).
* - `fallback`: system resolution failed and a cached proxy was used as fallback.
*/
export type ProxyResolveSource = 'localhost' | 'noProxyConfig' | 'noProxyEnv' | 'setting' | 'env' | 'remote' | 'system_cached' | 'system' | 'fallback';

/**
* Structured result of {@link createProxyResolver}'s `resolveProxyByURL`.
*/
export interface ResolvedProxyInfo {
/** The resolved proxy URL, or `undefined` for a direct connection. */
url: string | undefined;
/** The resolved proxy type. */
type: ProxyResolveType;
/** Which configuration determined the result. */
source: ProxyResolveSource;
}

export function createProxyResolver(params: ProxyAgentParams) {
const { getProxyURL, log, proxyResolveTelemetry: proxyResolverTelemetry, env } = params;
let envProxy = proxyFromConfigURL(env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY); // Not standardized.
Expand Down Expand Up @@ -178,13 +207,13 @@ export function createProxyResolver(params: ProxyAgentParams) {
}, flags.testCertificates);
}

function useProxySettings(url: string, req: http.ClientRequest | undefined, stackText: string, callback: (proxy?: string) => void) {
function useProxySettings(url: string, req: http.ClientRequest | undefined, stackText: string, callback: (proxy: string | undefined, source: ProxyResolveSource) => void) {
const parsedUrl = nodeurl.parse(url); // Coming from Node's URL, sticking with that.

const hostname = parsedUrl.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '::ffff:127.0.0.1') {
localhostCount++;
callback('DIRECT');
callback('DIRECT', 'localhost');
log.debug('ProxyResolver#resolveProxy localhost', url, 'DIRECT', stackText);
return;
}
Expand All @@ -198,14 +227,14 @@ export function createProxyResolver(params: ProxyAgentParams) {
let configNoProxy = noProxyFromConfig(noProxyConfig); // Not standardized.
if (typeof hostname === 'string' && configNoProxy(hostname, String(parsedUrl.port || defaultPort))) {
configNoProxyCount++;
callback('DIRECT');
callback('DIRECT', 'noProxyConfig');
log.debug('ProxyResolver#resolveProxy configNoProxy', url, 'DIRECT', stackText);
return;
}
} else {
if (typeof hostname === 'string' && envNoProxy(hostname, String(parsedUrl.port || defaultPort))) {
envNoProxyCount++;
callback('DIRECT');
callback('DIRECT', 'noProxyEnv');
log.debug('ProxyResolver#resolveProxy envNoProxy', url, 'DIRECT', stackText);
return;
}
Expand All @@ -214,20 +243,20 @@ export function createProxyResolver(params: ProxyAgentParams) {
let settingsProxy = proxyFromConfigURL(getProxyURL());
if (settingsProxy) {
settingsCount++;
callback(settingsProxy);
callback(settingsProxy, 'setting');
log.debug('ProxyResolver#resolveProxy settings', url, settingsProxy, stackText);
return;
}

if (envProxy) {
envCount++;
callback(envProxy);
callback(envProxy, 'env');
log.debug('ProxyResolver#resolveProxy env', url, envProxy, stackText);
return;
}

if (!params.isUseHostProxyEnabled()) {
callback('DIRECT');
callback('DIRECT', 'remote');
log.debug('ProxyResolver#resolveProxy unconfigured', url, 'DIRECT', stackText);
return;
}
Expand All @@ -239,7 +268,7 @@ export function createProxyResolver(params: ProxyAgentParams) {
if (req) {
collectResult(results, proxy, secureEndpoint ? 'HTTPS' : 'HTTP', req);
}
callback(proxy);
callback(proxy, 'system_cached');
log.debug('ProxyResolver#resolveProxy cached', url, proxy, stackText);
return;
}
Expand All @@ -253,15 +282,15 @@ export function createProxyResolver(params: ProxyAgentParams) {
collectResult(results, proxy, secureEndpoint ? 'HTTPS' : 'HTTP', req);
}
}
callback(proxy);
callback(proxy, 'system');
log.debug('ProxyResolver#resolveProxy', url, proxy, stackText);
}).then(() => {
count++;
duration = Date.now() - start + duration;
}, err => {
errorCount++;
const fallback: string | undefined = cache.values().next().value; // fall back to any proxy (https://github.com/microsoft/vscode/issues/122825)
callback(fallback);
callback(fallback, 'fallback');
log.error('ProxyResolver#resolveProxy', fallback, toErrorMessage(err), stackText);
});
}
Expand All @@ -277,6 +306,16 @@ export function createProxyResolver(params: ProxyAgentParams) {
}
});
}),
resolveProxyByURL: (url: string) => new Promise<ResolvedProxyInfo>((resolve, reject) => {
useProxySettings(url, undefined, '', (result, source) => {
try {
const { url: proxyURL, type } = getProxyURLFromResolverResult(result);
resolve({ url: proxyURL, type, source });
} catch (err) {
reject(err);
}
});
}),
};
}

Expand Down
130 changes: 130 additions & 0 deletions tests/src/resolveProxyByURL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as assert from 'assert';
import { createProxyResolver, LogLevel, ProxyAgentParams } from '../../src';

function createParams(overrides: Partial<ProxyAgentParams>): ProxyAgentParams {
const noop = () => { };
return {
resolveProxy: async () => undefined,
getProxyURL: () => undefined,
getProxySupport: () => 'override',
getNoProxyConfig: () => [],
isAdditionalFetchSupportEnabled: () => true,
isWebSocketPatchEnabled: () => true,
addCertificatesV1: () => false,
addCertificatesV2: () => false,
loadSystemCertificatesFromNode: () => false,
loadAdditionalCertificates: async () => [],
log: { trace: noop, debug: noop, info: noop, warn: noop, error: noop },
getLogLevel: () => LogLevel.Off,
proxyResolveTelemetry: noop,
isUseHostProxyEnabled: () => true,
env: {},
...overrides,
};
}

describe('resolveProxyByURL', function () {
it('reports localhost as a direct connection', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({}));
assert.deepStrictEqual(await resolveProxyByURL('http://localhost:3000/'), {
url: undefined, type: 'DIRECT', source: 'localhost',
});
});

it('reports the http.proxy setting source', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
getProxyURL: () => 'http://proxy.example.com:8080',
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: 'http://proxy.example.com:8080', type: 'PROXY', source: 'setting',
});
});

it('reports the environment variable source', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
env: { https_proxy: 'https://envproxy.example.com:3128' },
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: 'https://envproxy.example.com:3128', type: 'HTTPS', source: 'env',
});
});

it('honors the no_proxy environment variable', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
env: { https_proxy: 'https://envproxy.example.com:3128', no_proxy: 'example.com' },
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: undefined, type: 'DIRECT', source: 'noProxyEnv',
});
});

it('honors the http.noProxy setting over env variables', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
env: { https_proxy: 'https://envproxy.example.com:3128' },
getNoProxyConfig: () => ['example.com'],
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: undefined, type: 'DIRECT', source: 'noProxyConfig',
});
});

it('reports the system/PAC source with the resolved type', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
resolveProxy: async () => 'SOCKS socksproxy.example.com:1080',
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: 'socks://socksproxy.example.com:1080', type: 'SOCKS', source: 'system',
});
});

it('preserves the unnormalized SOCKS5 and HTTP scheme tokens', async function () {
const socks5 = createProxyResolver(createParams({
resolveProxy: async () => 'SOCKS5 socksproxy.example.com:1080',
}));
assert.deepStrictEqual(await socks5.resolveProxyByURL('https://example.com/'), {
url: 'socks://socksproxy.example.com:1080', type: 'SOCKS5', source: 'system',
});
const http = createProxyResolver(createParams({
resolveProxy: async () => 'HTTP proxy.example.com:8080',
}));
assert.deepStrictEqual(await http.resolveProxyByURL('https://example.com/'), {
url: 'http://proxy.example.com:8080', type: 'HTTP', source: 'system',
});
});

it('reports a direct connection resolved by the system', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
resolveProxy: async () => 'DIRECT',
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: undefined, type: 'DIRECT', source: 'system',
});
});

it('reports EMPTY when the system resolver returns nothing', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
resolveProxy: async () => undefined,
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: undefined, type: 'EMPTY', source: 'system',
});
});

it('reports UNRECOGNIZED when the system result has no known scheme', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
resolveProxy: async () => 'BOGUS proxy.example.com:9999',
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: undefined, type: 'UNRECOGNIZED', source: 'system',
});
});

it('reports remote when host proxy resolution is disabled', async function () {
const { resolveProxyByURL } = createProxyResolver(createParams({
isUseHostProxyEnabled: () => false,
}));
assert.deepStrictEqual(await resolveProxyByURL('https://example.com/'), {
url: undefined, type: 'DIRECT', source: 'remote',
});
});
});
Loading