diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9d87b..78440b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/package.json b/package.json index 0c2f159..400f23b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agent.ts b/src/agent.ts index 4bcfb63..05b7e22 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -143,10 +143,20 @@ export function sanitizeProxyResultCredentials(result: string | undefined): stri return String(result).replace(/(\b(?:PROXY|HTTPS?|SOCKS[45]?)\s+)[^\s@]+@/gi, '$1@'); } -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) @@ -159,13 +169,13 @@ 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' || @@ -173,10 +183,12 @@ export function getProxyURLFromResolverResult(result: string | undefined) { ) { // 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) => Promise; diff --git a/src/index.ts b/src/index.ts index c130c3b..c460300 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -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. @@ -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; } @@ -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; } @@ -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; } @@ -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; } @@ -253,7 +282,7 @@ 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++; @@ -261,7 +290,7 @@ export function createProxyResolver(params: ProxyAgentParams) { }, 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); }); } @@ -277,6 +306,16 @@ export function createProxyResolver(params: ProxyAgentParams) { } }); }), + resolveProxyByURL: (url: string) => new Promise((resolve, reject) => { + useProxySettings(url, undefined, '', (result, source) => { + try { + const { url: proxyURL, type } = getProxyURLFromResolverResult(result); + resolve({ url: proxyURL, type, source }); + } catch (err) { + reject(err); + } + }); + }), }; } diff --git a/tests/src/resolveProxyByURL.test.ts b/tests/src/resolveProxyByURL.test.ts new file mode 100644 index 0000000..48c07b8 --- /dev/null +++ b/tests/src/resolveProxyByURL.test.ts @@ -0,0 +1,130 @@ +import * as assert from 'assert'; +import { createProxyResolver, LogLevel, ProxyAgentParams } from '../../src'; + +function createParams(overrides: Partial): 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', + }); + }); +});