diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 5ae81222a6b..d5d0eca6dc7 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -1,3 +1,5 @@ +import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests } from '@/constants'; +import type { ICredentialsOverwrite } from '@/interfaces'; import { inDevelopment, inProduction } from '@n8n/backend-common'; import { SecurityConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -8,20 +10,18 @@ import express from 'express'; import { access as fsAccess } from 'fs/promises'; import helmet from 'helmet'; import isEmpty from 'lodash/isEmpty'; -import { InstanceSettings } from 'n8n-core'; +import { InstanceSettings, installGlobalProxyAgent } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import { resolve } from 'path'; import { AbstractServer } from '@/abstract-server'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; -import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests } from '@/constants'; import { ControllerRegistry } from '@/controller.registry'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { EventService } from '@/events/event.service'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; -import type { ICredentialsOverwrite } from '@/interfaces'; import { isLdapEnabled } from '@/ldap.ee/helpers.ee'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { handleMfaDisable, isMfaFeatureEnabled } from '@/mfa/helpers'; @@ -479,6 +479,8 @@ export class Server extends AbstractServer { } else { this.app.use('/', express.static(staticCacheDir, cacheOptions)); } + + installGlobalProxyAgent(); } protected setupPushServer(): void { diff --git a/packages/core/src/__tests__/http-proxy.test.ts b/packages/core/src/__tests__/http-proxy.test.ts new file mode 100644 index 00000000000..02184498939 --- /dev/null +++ b/packages/core/src/__tests__/http-proxy.test.ts @@ -0,0 +1,254 @@ +import http from 'http'; +import https from 'https'; +import type { AddressInfo } from 'net'; +import nock from 'nock'; +import { promisify } from 'util'; + +import { installGlobalProxyAgent, uninstallGlobalProxyAgent } from '../http-proxy'; + +interface TestResponse { + message: string; + timestamp: number; +} + +interface ProxyRequest { + method: string; + url: string; + timestamp: number; +} + +async function createMockProxyServer() { + const capturedRequests: ProxyRequest[] = []; + const server = http.createServer((req, res) => { + capturedRequests.push({ + method: req.method ?? 'GET', + url: req.url ?? '', + timestamp: Date.now(), + }); + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'proxied', timestamp: Date.now() })); + }); + + server.on('connect', (req, clientSocket) => { + capturedRequests.push({ + method: 'CONNECT', + url: req.url ?? '', + timestamp: Date.now(), + }); + + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + clientSocket.end(); + }); + + await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', resolve); + server.on('error', reject); + }); + + const address = server.address() as AddressInfo; + + return { + server, + port: address.port, + url: `http://127.0.0.1:${address.port}`, + capturedRequests, + clearRequests: () => (capturedRequests.length = 0), + }; +} + +async function makeRequest(url: string): Promise { + return await new Promise((resolve, reject) => { + const urlObj = new URL(url); + const httpModule = urlObj.protocol === 'https:' ? https : http; + + const req = httpModule.get(url, { timeout: 5000 }, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + }); +} + +describe('HTTP Proxy Tests', () => { + let proxyServer: Awaited>; + + beforeAll(async () => { + proxyServer = await createMockProxyServer(); + }); + + afterAll(async () => { + await promisify(proxyServer.server.close.bind(proxyServer.server))(); + }); + + beforeEach(() => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.NO_PROXY; + delete process.env.ALL_PROXY; + nock.cleanAll(); + nock.restore(); + }); + + afterEach(() => { + uninstallGlobalProxyAgent(); + proxyServer.clearRequests(); + nock.cleanAll(); + }); + + test.each([ + { + name: 'should use HTTP_PROXY for HTTP requests', + env: { HTTP_PROXY: true }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: true, + }, + { + name: 'should ignore HTTPS_PROXY for HTTP requests', + env: { HTTPS_PROXY: true }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: false, + }, + { + name: 'should use ALL_PROXY when specific proxy not set', + env: { ALL_PROXY: true }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: true, + }, + { + name: 'should prefer HTTP_PROXY over ALL_PROXY', + env: { HTTP_PROXY: true, ALL_PROXY: 'http://unused:8080' }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: true, + }, + { + name: 'should make direct requests when no proxy configured', + env: {}, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: false, + }, + { + name: 'should bypass proxy for exact hostname match', + env: { HTTP_PROXY: true, NO_PROXY: 'api.example.com' }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: false, + }, + { + name: 'should bypass proxy for exact IP match', + env: { HTTP_PROXY: true, NO_PROXY: '192.168.1.100' }, + targetUrl: 'http://192.168.1.100:8080/test', + expectProxied: false, + }, + { + name: 'should proxy when hostname not in NO_PROXY', + env: { HTTP_PROXY: true, NO_PROXY: 'example.com' }, + targetUrl: 'http://test.local:8080/api', + expectProxied: true, + }, + { + name: 'should bypass proxy for wildcard subdomain patterns', + env: { HTTP_PROXY: true, NO_PROXY: '*.local' }, + targetUrl: 'http://app.local:8080/api', + expectProxied: false, + }, + { + name: 'should bypass proxy for nested wildcard patterns', + env: { HTTP_PROXY: true, NO_PROXY: '*.example.com' }, + targetUrl: 'http://api.example.com:8080/data', + expectProxied: false, + }, + { + name: 'should proxy when wildcard does not match', + env: { HTTP_PROXY: true, NO_PROXY: '*.example.com' }, + targetUrl: 'http://test.local:8080/api', + expectProxied: true, + }, + { + name: 'should handle multiple NO_PROXY patterns - match first', + env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.local,example.com' }, + targetUrl: 'http://localhost:8080/test', + expectProxied: false, + }, + { + name: 'should handle multiple NO_PROXY patterns - match middle', + env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.local,example.com' }, + targetUrl: 'http://app.local:8080/api', + expectProxied: false, + }, + { + name: 'should handle multiple NO_PROXY patterns - match last', + env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.local,example.com' }, + targetUrl: 'http://example.com:8080/data', + expectProxied: false, + }, + { + name: 'should proxy when none of multiple patterns match', + env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.example.com,test.org' }, + targetUrl: 'http://app.local:8080/api', + expectProxied: true, + }, + { + name: 'should respect NO_PROXY with ALL_PROXY', + env: { ALL_PROXY: true, NO_PROXY: '*.example.com' }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: false, + }, + { + name: 'should proxy when target not in NO_PROXY list', + env: { HTTP_PROXY: true, NO_PROXY: 'localhost,*.internal' }, + targetUrl: 'http://api.example.com:8080/test', + expectProxied: true, + }, + ])('$name', async ({ env, targetUrl, expectProxied }) => { + if (env.HTTP_PROXY) process.env.HTTP_PROXY = proxyServer.url; + if (env.HTTPS_PROXY) process.env.HTTPS_PROXY = proxyServer.url; + if (env.ALL_PROXY === true) process.env.ALL_PROXY = proxyServer.url; + if (env.ALL_PROXY && env.ALL_PROXY !== true) process.env.ALL_PROXY = env.ALL_PROXY; + if (env.NO_PROXY) process.env.NO_PROXY = env.NO_PROXY; + + installGlobalProxyAgent(); + + let scope: nock.Scope | undefined; + if (!expectProxied) { + scope = setupDirectRequestMock(targetUrl); + } + + const response = await makeRequest(targetUrl); + + if (expectProxied) { + expectProxiedResponse(response); + } else { + expectDirectResponse(response, scope); + } + }); + + function setupDirectRequestMock(targetUrl: string) { + if (!nock.isActive()) nock.activate(); + const url = new URL(targetUrl); + return nock(`${url.protocol}//${url.host}`) + .get(url.pathname) + .reply(200, { message: 'direct', timestamp: Date.now() }); + } + + function expectProxiedResponse(response: TestResponse) { + expect(response.message).toBe('proxied'); + expect(proxyServer.capturedRequests.length).toBeGreaterThan(0); + } + + function expectDirectResponse(response: TestResponse, scope?: nock.Scope) { + expect(response.message).toBe('direct'); + expect(proxyServer.capturedRequests).toHaveLength(0); + expect(scope?.isDone()).toBe(true); + } +}); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts index 84f393d240b..414a9543695 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts @@ -1,8 +1,5 @@ import FormData from 'form-data'; -import { Agent as HttpAgent } from 'http'; -import { HttpProxyAgent } from 'http-proxy-agent'; -import { Agent as HttpsAgent } from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; +import type { Agent as HttpsAgent } from 'https'; import { mock } from 'jest-mock-extended'; import type { IHttpRequestMethods, @@ -22,7 +19,6 @@ import { applyPaginationRequestData, convertN8nRequestToAxios, createFormDataObject, - getAgentWithProxy, httpRequest, invokeAxios, parseRequestObject, @@ -870,90 +866,4 @@ describe('Request Helper Functions', () => { scope.done(); }); }); - - describe('getAgentWithProxy', () => { - const baseUrlHttps = 'https://example.com'; - const baseUrlHttp = 'http://example.com'; - const proxyUrlHttps = 'http://proxy-for-https.com:8080/'; - const proxyUrlHttp = 'http://proxy-for-http.com:8080/'; - - test('should return a regular HTTP agent when no proxy is set', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: baseUrlHttp, - }); - expect(protocol).toEqual('http'); - expect(agent).toBeInstanceOf(HttpAgent); - }); - - test('should return a regular HTTPS agent when no proxy is set', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: baseUrlHttps, - }); - expect(protocol).toEqual('https'); - expect(agent).toBeInstanceOf(HttpsAgent); - }); - - test('should use a proxyConfig object', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: baseUrlHttps, - proxyConfig: { - host: 'proxy-for-https.com', - port: 8080, - }, - }); - expect(protocol).toEqual('https'); - expect((agent as HttpsProxyAgent).proxy.href).toEqual(proxyUrlHttps); - }); - - test('should use a proxyConfig string', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: baseUrlHttps, - proxyConfig: proxyUrlHttps, - }); - expect(agent).toBeInstanceOf(HttpsProxyAgent); - expect(protocol).toEqual('https'); - expect((agent as HttpsProxyAgent).proxy.href).toEqual(proxyUrlHttps); - }); - - describe('environment variables', () => { - let originalEnv: NodeJS.ProcessEnv; - - beforeAll(() => { - originalEnv = { ...process.env }; - process.env.HTTP_PROXY = proxyUrlHttp; - process.env.HTTPS_PROXY = proxyUrlHttps; - process.env.NO_PROXY = 'should-not-proxy.com'; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - test('should proxy http requests (HTTP_PROXY)', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: baseUrlHttp, - }); - expect(protocol).toEqual('http'); - expect(agent).toBeInstanceOf(HttpProxyAgent); - expect((agent as HttpsProxyAgent).proxy.href).toEqual(proxyUrlHttp); - }); - - test('should proxy https requests (HTTPS_PROXY)', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: baseUrlHttps, - }); - expect(protocol).toEqual('https'); - expect(agent).toBeInstanceOf(HttpsProxyAgent); - expect((agent as HttpsProxyAgent).proxy.href).toEqual(proxyUrlHttps); - }); - - test('should not proxy some hosts based on NO_PROXY', async () => { - const { agent, protocol } = getAgentWithProxy({ - targetUrl: 'https://should-not-proxy.com/foo', - }); - expect(protocol).toEqual('https'); - expect(agent).toBeInstanceOf(HttpsAgent); - }); - }); - }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts index 20bad1b1c54..41d7134ee9a 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts @@ -19,10 +19,8 @@ import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from import axios from 'axios'; import crypto, { createHmac } from 'crypto'; import FormData from 'form-data'; -import { IncomingMessage, Agent as HttpAgent } from 'http'; -import { HttpProxyAgent } from 'http-proxy-agent'; -import { type AgentOptions, Agent as HttpsAgent } from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; +import { IncomingMessage } from 'http'; +import { type AgentOptions } from 'https'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import merge from 'lodash/merge'; @@ -63,10 +61,10 @@ import type { } from 'n8n-workflow'; import type { Token } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; -import proxyFromEnv from 'proxy-from-env'; import { stringify } from 'qs'; import { Readable } from 'stream'; +import { createHttpProxyAgent, createHttpsProxyAgent } from '@/http-proxy'; import type { IResponseError } from '@/interfaces'; import { binaryToString } from './binary-helper-functions'; @@ -89,95 +87,63 @@ axios.defaults.proxy = false; function validateUrl(url?: string): boolean { if (!url) return false; - try { new URL(url); return true; - } catch (error) { + } catch { return false; } } function getUrlFromProxyConfig(proxyConfig: IHttpRequestOptions['proxy'] | string): string | null { if (typeof proxyConfig === 'string') { - if (!validateUrl(proxyConfig)) { - return null; - } - return proxyConfig; + return validateUrl(proxyConfig) ? proxyConfig : null; } - if (!proxyConfig?.host) { - return null; - } + if (!proxyConfig?.host) return null; const { protocol, host, port, auth } = proxyConfig; - const safeProtocol = protocol?.endsWith(':') ? protocol.replace(':', '') : (protocol ?? 'http'); + const safeProtocol = protocol?.endsWith(':') ? protocol.slice(0, -1) : (protocol ?? 'http'); try { const url = new URL(`${safeProtocol}://${host}`); - - if (port !== undefined) { - url.port = String(port); - } - + if (port !== undefined) url.port = String(port); if (auth?.username) { url.username = auth.username; url.password = auth.password ?? ''; } - return url.href; - } catch (error) { + } catch { return null; } } -function getTargetUrlFromAxiosConfig(axiosConfig: AxiosRequestConfig): string { - const { url, baseURL } = axiosConfig; +function buildTargetUrl(url?: string, baseURL?: string): string | undefined { + if (!url) return undefined; try { - return new URL(url ?? '', baseURL).href; + return baseURL ? new URL(url, baseURL).href : url; } catch { - return ''; + return undefined; } } -type AgentInfo = { protocol: 'http' | 'https'; agent: HttpAgent }; - -export function getAgentWithProxy({ - agentOptions, - proxyConfig, - targetUrl, -}: { - agentOptions?: AgentOptions; - proxyConfig?: IHttpRequestOptions['proxy'] | string; - targetUrl: string; -}): AgentInfo { - // If no proxy is set in config, use HTTP_PROXY/HTTPS_PROXY/ALL_PROXY from env depending on target URL - // Also respect NO_PROXY to disable the proxy for certain hosts - const proxyUrl = getUrlFromProxyConfig(proxyConfig) ?? proxyFromEnv.getProxyForUrl(targetUrl); - const protocol = targetUrl.startsWith('https://') ? 'https' : 'http'; - - if (proxyUrl) { - const ProxyAgent = protocol === 'http' ? HttpProxyAgent : HttpsProxyAgent; - return { protocol, agent: new ProxyAgent(proxyUrl, agentOptions) }; - } - - const Agent = protocol === 'http' ? HttpAgent : HttpsAgent; - return { protocol, agent: new Agent(agentOptions) }; -} - -const applyAgentToAxiosConfig = ( +function setAxiosAgents( config: AxiosRequestConfig, - { agent, protocol }: AgentInfo, -): AxiosRequestConfig => { - if (protocol === 'http') { - config.httpAgent = agent; - } else { - config.httpsAgent = agent; - } + agentOptions?: AgentOptions, + proxyConfig?: IHttpRequestOptions['proxy'] | string, +): void { + if (config.httpAgent || config.httpsAgent) return; - return config; -}; + const customProxyUrl = getUrlFromProxyConfig(proxyConfig); + + const targetUrl = buildTargetUrl(config.url, config.baseURL); + + if (!targetUrl) return; + + config.httpAgent = createHttpProxyAgent(customProxyUrl, targetUrl, agentOptions); + config.httpsAgent = createHttpsProxyAgent(customProxyUrl, targetUrl, agentOptions); +} axios.interceptors.request.use((config) => { // If no content-type is set by us, prevent axios from force-setting the content-type to `application/x-www-form-urlencoded` @@ -185,13 +151,7 @@ axios.interceptors.request.use((config) => { config.headers.setContentType(false, false); } - if (!config.httpsAgent && !config.httpAgent) { - const agent = getAgentWithProxy({ - targetUrl: getTargetUrlFromAxiosConfig(config), - }); - - applyAgentToAxiosConfig(config, agent); - } + setAxiosAgents(config); return config; }); @@ -230,22 +190,21 @@ const getBeforeRedirectFn = (redirectedRequest: Record) => { const redirectAgentOptions = { ...agentOptions, - servername: redirectedRequest.hostname, }; - const redirectAgent = getAgentWithProxy({ - agentOptions: redirectAgentOptions, - proxyConfig, - targetUrl: redirectedRequest.href, - }); + const customProxyUrl = getUrlFromProxyConfig(proxyConfig); - redirectedRequest.agent = redirectAgent.agent; - if (redirectAgent.protocol === 'http') { - redirectedRequest.agents.http = redirectAgent.agent; - } else { - redirectedRequest.agents.https = redirectAgent.agent; - } + // Create both agents and set them + const targetUrl = redirectedRequest.href; + const httpAgent = createHttpProxyAgent(customProxyUrl, targetUrl, redirectAgentOptions); + const httpsAgent = createHttpsProxyAgent(customProxyUrl, targetUrl, redirectAgentOptions); + redirectedRequest.agent = redirectedRequest.href.startsWith('https://') + ? httpsAgent + : httpAgent; + redirectedRequest.agents = { http: httpAgent, https: httpsAgent }; + + // Copy auth headers if (axiosConfig.headers?.Authorization) { redirectedRequest.headers.Authorization = axiosConfig.headers.Authorization; } @@ -613,13 +572,7 @@ export async function parseRequestObject(requestObject: IRequestOptions) { axiosConfig.timeout = requestObject.timeout; } - const agent = getAgentWithProxy({ - agentOptions, - proxyConfig: requestObject.proxy, - targetUrl: getTargetUrlFromAxiosConfig(axiosConfig), - }); - - applyAgentToAxiosConfig(axiosConfig, agent); + setAxiosAgents(axiosConfig, agentOptions, requestObject.proxy); axiosConfig.beforeRedirect = getBeforeRedirectFn(agentOptions, axiosConfig, requestObject.proxy); @@ -799,12 +752,7 @@ export function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): Axios if (n8nRequest.skipSslCertificateValidation === true) { agentOptions.rejectUnauthorized = false; } - const agent = getAgentWithProxy({ - agentOptions, - proxyConfig: proxy, - targetUrl: getTargetUrlFromAxiosConfig(axiosRequest), - }); - applyAgentToAxiosConfig(axiosRequest, agent); + setAxiosAgents(axiosRequest, agentOptions, proxy); axiosRequest.beforeRedirect = getBeforeRedirectFn(agentOptions, axiosRequest, n8nRequest.proxy); diff --git a/packages/core/src/http-proxy.ts b/packages/core/src/http-proxy.ts new file mode 100644 index 00000000000..695add89cbd --- /dev/null +++ b/packages/core/src/http-proxy.ts @@ -0,0 +1,144 @@ +import http from 'http'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import https from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { LoggerProxy } from 'n8n-workflow'; +import proxyFromEnv from 'proxy-from-env'; + +type ProxyRequestParameters = Parameters['addRequest']>; +type ProxyClientRequest = ProxyRequestParameters[0]; +type ProxyRequestOptions = ProxyRequestParameters[1]; + +function buildTargetUrl(hostname: string, port: number, protocol: 'http' | 'https'): string { + const defaultPort = protocol === 'https' ? 443 : 80; + const portSuffix = port === defaultPort ? '' : `:${port}`; + return `${protocol}://${hostname}${portSuffix}`; +} + +function extractHostInfo( + options: http.RequestOptions, + defaultPort: number, +): { hostname: string; port: number } { + const hostname = options.hostname ?? options.host ?? 'localhost'; + const port = + typeof options.port === 'string' ? parseInt(options.port, 10) : (options.port ?? defaultPort); + return { hostname: String(hostname), port: Number(port) }; +} + +function getOrCreateProxyAgent | HttpsProxyAgent>( + cache: Map, + proxyUrl: string, + createAgent: (url: string) => T, +): T { + let proxyAgent = cache.get(proxyUrl); + if (!proxyAgent) { + proxyAgent = createAgent(proxyUrl); + cache.set(proxyUrl, proxyAgent); + } + return proxyAgent; +} + +function createFallbackAgent(AgentClass: new () => T): T { + return new AgentClass(); +} + +/** + * Node.js is working on native HTTP proxy support (as of Node.js 24) + * When it is stable we can use it and remove this implementation + * + * https://nodejs.org/api/http.html#built-in-proxy-support + */ +class HttpProxyManager extends http.Agent { + private readonly proxyAgentCache = new Map>(); + private readonly fallbackAgent = createFallbackAgent(http.Agent); + + addRequest(req: http.ClientRequest, options: http.RequestOptions) { + const { hostname, port } = extractHostInfo(options, 80); + const targetUrl = buildTargetUrl(hostname, port, 'http'); + const proxyUrl = proxyFromEnv.getProxyForUrl(targetUrl); + + if (proxyUrl) { + const proxyAgent = getOrCreateProxyAgent( + this.proxyAgentCache, + proxyUrl, + (url) => new HttpProxyAgent(url), + ); + return proxyAgent.addRequest(req as ProxyClientRequest, options as ProxyRequestOptions); + } + + return this.fallbackAgent.addRequest(req, options); + } +} + +class HttpsProxyManager extends https.Agent { + private readonly proxyAgentCache = new Map>(); + private readonly fallbackAgent = createFallbackAgent(https.Agent); + + addRequest(req: http.ClientRequest, options: https.RequestOptions) { + const { hostname, port } = extractHostInfo(options, 443); + const targetUrl = buildTargetUrl(hostname, port, 'https'); + const proxyUrl = proxyFromEnv.getProxyForUrl(targetUrl); + + if (proxyUrl) { + const proxyAgent = getOrCreateProxyAgent( + this.proxyAgentCache, + proxyUrl, + (url) => new HttpsProxyAgent(url), + ); + return proxyAgent.addRequest(req, options); + } + + return this.fallbackAgent.addRequest(req, options); + } +} + +export function createHttpProxyAgent( + customProxyUrl: string | null = null, + targetUrl: string, + options?: http.AgentOptions, +): http.Agent { + const proxyUrl = customProxyUrl ?? proxyFromEnv.getProxyForUrl(targetUrl); + + if (proxyUrl) { + return new HttpProxyAgent(proxyUrl, options); + } + + return new http.Agent(options); +} + +export function createHttpsProxyAgent( + customProxyUrl: string | null = null, + targetUrl: string, + options?: https.AgentOptions, +): https.Agent { + const proxyUrl = customProxyUrl ?? proxyFromEnv.getProxyForUrl(targetUrl); + + if (proxyUrl) { + return new HttpsProxyAgent(proxyUrl, options); + } + + return new https.Agent(options); +} + +function hasProxyEnvironmentVariables(): boolean { + return Boolean(process.env.HTTP_PROXY ?? process.env.HTTPS_PROXY ?? process.env.ALL_PROXY); +} + +export function installGlobalProxyAgent(): void { + if (hasProxyEnvironmentVariables()) { + LoggerProxy.debug('Installing global HTTP proxy agents', { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + NO_PROXY: process.env.NO_PROXY, + ALL_PROXY: process.env.ALL_PROXY, + }); + + http.globalAgent = new HttpProxyManager(); + https.globalAgent = new HttpsProxyManager(); + } +} + +export function uninstallGlobalProxyAgent(): void { + http.globalAgent = new http.Agent(); + https.globalAgent = new https.Agent(); +} diff --git a/packages/core/src/http.d.ts b/packages/core/src/http.d.ts new file mode 100644 index 00000000000..74b0e0a4043 --- /dev/null +++ b/packages/core/src/http.d.ts @@ -0,0 +1,14 @@ +import type http from 'http-proxy-agent'; +import type https from 'https-proxy-agent'; + +declare module 'http' { + interface Agent { + addRequest(req: ClientRequest, options: http.HttpProxyAgentOptions): void; + } +} + +declare module 'https' { + interface Agent { + addRequest(req: ClientRequest, options: https.HttpsProxyAgentOptions): void; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 846e02ed0cd..570e9547df7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from './html-sandbox'; export * from './instance-settings'; export * from './nodes-loader'; export * from './utils'; +export * from './http-proxy'; export { WorkflowHasIssuesError } from './errors/workflow-has-issues.error'; export type * from './interfaces';