Skip to content

Commit 0e19610

Browse files
committed
http,https: add built-in proxy support in http/https.request and Agent
This patch implements proxy support for HTTP and HTTPS clients and agents in the `http` and `https` built-ins`. When NODE_USE_ENV_PROXY is set to 1, the default global agent would parse the HTTP_PROXY/http_proxy, HTTPS_PROXY/https_proxy, NO_PROXY/no_proxy settings from the environment variables, and proxy the requests sent through the built-in http/https client accordingly. To support this, `http.Agent` and `https.Agent` now accept a few new options: - `proxyEnv`: when it's an object, the agent would read and parse the HTTP_PROXY/http_proxy, HTTPS_PROXY/https_proxy, NO_PROXY/no_proxy properties from it, and apply them based on the protocol it uses to send requests. This option allows custom agents to reuse built-in proxy support by composing options. Global agents set this to `process.env` when NODE_USE_ENV_PROXY is 1. - `defaultPort` and `protocol`: these allow setting of the default port and protocol of the agents. We also need these when configuring proxy settings and deciding whether a request should be proxied. Implementation-wise, this adds a `ProxyConfig` internal class to handle parsing and application of proxy configurations. The configuration is parsed during agent construction. When requests are made, the `createConnection()` methods on the agents would check whether the request should be proxied. If yes, they either connect to the proxy server (in the case of HTTP reqeusts) or establish a tunnel (in the case of HTTPS requests) through either a TCP socket (if the proxy uses HTTP) or a TLS socket (if the proxy uses HTTPS). When proxying HTTPS requests through a tunnel, the connection listener is invoked after the tunnel is established. Tunnel establishment uses the timeout of the request options, if there is one. Otherwise it uses the timeout of the agent. If an error is encountered during tunnel establishment, an ERR_PROXY_TUNNEL would be emitted on the returned socket. If the proxy server sends a errored status code, the error would contain an `statusCode` property. If the error is caused by timeout, the error would contain a `proxyTunnelTimeout` property. This implementation honors the built-in socket pool and socket limits. Pooled sockets are still keyed by request endpoints, they are just connected to the proxy server instead, and the persistence of the connection can be maintained as long as the proxy server respects proxy-connection.
1 parent 34a4172 commit 0e19610

File tree

46 files changed

+2995
-36
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2995
-36
lines changed

doc/api/errors.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2489,6 +2489,12 @@ Accessing `Object.prototype.__proto__` has been forbidden using
24892489
[`Object.setPrototypeOf`][] should be used to get and set the prototype of an
24902490
object.
24912491

2492+
<a id="ERR_PROXY_TUNNEL"></a>
2493+
2494+
### `ERR_PROXY_TUNNEL`
2495+
2496+
Failed to establish proxy tunnel when `NODE_USE_ENV_PROXY` is enabled.
2497+
24922498
<a id="ERR_QUIC_APPLICATION_ERROR"></a>
24932499

24942500
### `ERR_QUIC_APPLICATION_ERROR`

doc/api/http.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ http.get({
116116
<!-- YAML
117117
added: v0.3.4
118118
changes:
119+
- version:
120+
- REPLACEME
121+
pr-url: REPLACEME
122+
description: Add support for `proxyEnv`.
123+
- version:
124+
- REPLACEME
125+
pr-url: REPLACEME
126+
description: Add support for `defaultPort` and `protocol`.
119127
- version:
120128
- v15.6.0
121129
- v14.17.0
@@ -178,6 +186,20 @@ changes:
178186
**Default:** `'lifo'`.
179187
* `timeout` {number} Socket timeout in milliseconds.
180188
This will set the timeout when the socket is created.
189+
* `proxyEnv` {Object|undefined} Environment variables for proxy configuration.
190+
See [Built-in Proxy Support][] for details. **Default:** `undefined`
191+
* `HTTP_PROXY` {string|undefined} URL for the proxy server that HTTP requests should use.
192+
If undefined, no proxy is used for HTTP requests.
193+
* `HTTPS_PROXY` {string|undefined} URL for the proxy server that HTTPS requests should use.
194+
If undefined, no proxy is used for HTTPS requests.
195+
* `NO_PROXY` {string|undefined} Patterns specifying the endpoints
196+
that should not be routed through a proxy.
197+
* `http_proxy` {string|undefined} Same as `HTTP_PROXY`. If both are set, `http_proxy` takes precedence.
198+
* `https_proxy` {string|undefined} Same as `HTTPS_PROXY`. If both are set, `https_proxy` takes precedence.
199+
* `no_proxy` {string|undefined} Same as `NO_PROXY`. If both are set, `no_proxy` takes precedence.
200+
* `defaultPort` {number} Default port to use when the port is not specified
201+
in requests. **Default:** `80`.
202+
* `protocol` {string} The protocol to use for the agent. **Default:** `'http:'`.
181203

182204
`options` in [`socket.connect()`][] are also supported.
183205

@@ -4243,6 +4265,98 @@ added:
42434265
42444266
A browser-compatible implementation of {WebSocket}.
42454267
4268+
## Built-in Proxy Support
4269+
4270+
<!-- YAML
4271+
added: REPLACEME
4272+
-->
4273+
4274+
> Stability: 1.1 - Active development
4275+
4276+
When Node.js creates the global agent, it checks the `NODE_USE_ENV_PROXY`
4277+
environment variable. If it is set to `1`, the global agent will be constructed
4278+
with `proxyEnv: process.env`, enabling proxy support based on the environment variables.
4279+
4280+
Custom agents can also be created with proxy support by passing a
4281+
`proxyEnv` option when constructing the agent. The value can be `process.env`
4282+
if they just want to inherit the configuration from the environment variables,
4283+
or an object with specific setting overriding the environment.
4284+
4285+
The following properties of the `proxyEnv` are checked to configure proxy
4286+
support.
4287+
4288+
* `HTTP_PROXY` or `http_proxy`: Proxy server URL for HTTP requests. If both are set,
4289+
`http_proxy` takes precedence.
4290+
* `HTTPS_PROXY` or `https_proxy`: Proxy server URL for HTTPS requests. If both are set,
4291+
`https_proxy` takes precedence.
4292+
* `NO_PROXY` or `no_proxy`: Comma-separated list of hosts to bypass the proxy. If both are set,
4293+
`no_proxy` takes precedence.
4294+
4295+
If the request is made to a Unix domain socket, the proxy settings will be ignored.
4296+
4297+
### Proxy URL Format
4298+
4299+
Proxy URLs can use either HTTP or HTTPS protocols:
4300+
4301+
* HTTP proxy: `http://proxy.example.com:8080`
4302+
* HTTPS proxy: `https://proxy.example.com:8080`
4303+
* Proxy with authentication: `http://username:[email protected]:8080`
4304+
4305+
### `NO_PROXY` Format
4306+
4307+
The `NO_PROXY` environment variable supports several formats:
4308+
4309+
* `*` - Bypass proxy for all hosts
4310+
* `example.com` - Exact host name match
4311+
* `.example.com` - Domain suffix match (matches `sub.example.com`)
4312+
* `*.example.com` - Wildcard domain match
4313+
* `192.168.1.100` - Exact IP address match
4314+
* `192.168.1.1-192.168.1.100` - IP address range
4315+
* `example.com:8080` - Hostname with specific port
4316+
4317+
Multiple entries should be separated by commas.
4318+
4319+
### Example
4320+
4321+
Starting a Node.js process with proxy support enabled for all requests sent
4322+
through the default global agent:
4323+
4324+
```console
4325+
NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://proxy.example.com:8080 NO_PROXY=localhost,127.0.0.1 node client.js
4326+
```
4327+
4328+
To create a custom agent with built-in proxy support:
4329+
4330+
```cjs
4331+
const http = require('node:http');
4332+
4333+
// Creating a custom agent with custom proxy support.
4334+
const agent = new http.Agent({ proxyEnv: { HTTP_PROXY: 'http://proxy.example.com:8080' } });
4335+
4336+
http.request({
4337+
hostname: 'www.example.com',
4338+
port: 80,
4339+
path: '/',
4340+
agent,
4341+
}, (res) => {
4342+
// This request will be proxied through proxy.example.com:8080 using the HTTP protocol.
4343+
console.log(`STATUS: ${res.statusCode}`);
4344+
});
4345+
```
4346+
4347+
Alternatively, the following also works:
4348+
4349+
```cjs
4350+
const http = require('node:http');
4351+
// Use lower-cased option name.
4352+
const agent1 = new http.Agent({ proxyEnv: { http_proxy: 'http://proxy.example.com:8080' } });
4353+
// Use values inherited from the environment variables, if the process is started with
4354+
// HTTP_PROXY=http://proxy.example.com:8080 this will use the proxy server specified
4355+
// in process.env.HTTP_PROXY.
4356+
const agent2 = new http.Agent({ proxyEnv: process.env });
4357+
```
4358+
4359+
[Built-in Proxy Support]: #built-in-proxy-support
42464360
[RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187.txt
42474361
[`'ERR_HTTP_CONTENT_LENGTH_MISMATCH'`]: errors.md#err_http_content_length_mismatch
42484362
[`'checkContinue'`]: #event-checkcontinue

lib/_http_agent.js

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,16 @@ const EventEmitter = require('events');
3434
let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
3535
debug = fn;
3636
});
37+
const {
38+
parseProxyConfigFromEnv,
39+
kProxyConfig,
40+
checkShouldUseProxy,
41+
kWaitForProxyTunnel,
42+
} = require('internal/http');
3743
const { AsyncResource } = require('async_hooks');
3844
const { async_id_symbol } = require('internal/async_hooks').symbols;
3945
const {
46+
getLazy,
4047
kEmptyObject,
4148
once,
4249
} = require('internal/util');
@@ -45,6 +52,7 @@ const {
4552
validateOneOf,
4653
validateString,
4754
} = require('internal/validators');
55+
const assert = require('internal/assert');
4856

4957
const kOnKeylog = Symbol('onkeylog');
5058
const kRequestOptions = Symbol('requestOptions');
@@ -84,11 +92,11 @@ function Agent(options) {
8492

8593
EventEmitter.call(this);
8694

87-
this.defaultPort = 80;
88-
this.protocol = 'http:';
89-
9095
this.options = { __proto__: null, ...options };
9196

97+
this.defaultPort = this.options.defaultPort || 80;
98+
this.protocol = this.options.protocol || 'http:';
99+
92100
if (this.options.noDelay === undefined)
93101
this.options.noDelay = true;
94102

@@ -104,6 +112,12 @@ function Agent(options) {
104112
this.scheduling = this.options.scheduling || 'lifo';
105113
this.maxTotalSockets = this.options.maxTotalSockets;
106114
this.totalSocketCount = 0;
115+
const proxyEnv = this.options.proxyEnv;
116+
this.options.proxyEnv = undefined; // Don't keep a reference to the env object.
117+
if (typeof proxyEnv === 'object' && proxyEnv !== null) {
118+
this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive);
119+
debug(`new ${this.protocol} agent with proxy config`, this[kProxyConfig]);
120+
}
107121

108122
validateOneOf(this.scheduling, 'scheduling', ['fifo', 'lifo']);
109123

@@ -200,9 +214,40 @@ function maybeEnableKeylog(eventName) {
200214
}
201215
}
202216

217+
const lazyTLS = getLazy(() => require('tls'));
218+
203219
Agent.defaultMaxSockets = Infinity;
204220

205-
Agent.prototype.createConnection = net.createConnection;
221+
// See ProxyConfig in internal/http.js for how the connection should be handled
222+
// when the agent is configured to use a proxy server.
223+
Agent.prototype.createConnection = function createConnection(...args) {
224+
const normalized = net._normalizeArgs(args);
225+
const options = normalized[0];
226+
const cb = normalized[1];
227+
228+
// Check if this specific request should bypass the proxy
229+
const shouldUseProxy = checkShouldUseProxy(this[kProxyConfig], options);
230+
debug(`http createConnection should use proxy for ${options.host}:${options.port}:`, shouldUseProxy);
231+
if (!shouldUseProxy) { // Forward to net.createConnection if no proxying is needed.
232+
return net.createConnection(...args);
233+
}
234+
235+
// Create a copy of the shared proxy connection options and connect
236+
// to the proxy server instead of the endpoint. For Agent.prototype.createConnection
237+
// which is used by the http agent, this is enough
238+
const connectOptions = {
239+
...this[kProxyConfig].proxyConnectionOptions,
240+
};
241+
const proxyProtocol = this[kProxyConfig].protocol;
242+
if (proxyProtocol === 'http:') {
243+
return net.connect(connectOptions, cb);
244+
} else if (proxyProtocol === 'https:') {
245+
return lazyTLS().connect(connectOptions, cb);
246+
}
247+
// This should be unreachable because proxy config should be null for other protocols.
248+
assert.fail(`Unexpected proxy protocol ${proxyProtocol}`);
249+
250+
};
206251

207252
// Get the key for a given set of request options
208253
Agent.prototype.getName = function getName(options = kEmptyObject) {
@@ -227,6 +272,16 @@ Agent.prototype.getName = function getName(options = kEmptyObject) {
227272
return name;
228273
};
229274

275+
function handleSocketAfterProxy(err, req) {
276+
if (err.code === 'ERR_PROXY_TUNNEL') {
277+
if (err.proxyTunnelTimeout) {
278+
req.emit('timeout'); // Propagate the timeout from the tunnel to the request.
279+
} else {
280+
req.emit('error', err);
281+
}
282+
}
283+
}
284+
230285
Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
231286
localAddress/* legacy */) {
232287
// Legacy API: addRequest(req, host, port, localAddress)
@@ -239,6 +294,7 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
239294
};
240295
}
241296

297+
// XXX: here the agent options will override per-request options.
242298
options = { __proto__: null, ...options, ...this.options };
243299
if (options.socketPath)
244300
options.path = options.socketPath;
@@ -264,20 +320,24 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
264320
const freeLen = freeSockets ? freeSockets.length : 0;
265321
const sockLen = freeLen + this.sockets[name].length;
266322

323+
// Reusing a socket from the pool.
267324
if (socket) {
268325
asyncResetHandle(socket);
269326
this.reuseSocket(socket, req);
270327
setRequestSocket(this, req, socket);
271328
this.sockets[name].push(socket);
272329
} else if (sockLen < this.maxSockets &&
273330
this.totalSocketCount < this.maxTotalSockets) {
274-
debug('call onSocket', sockLen, freeLen);
275331
// If we are under maxSockets create a new one.
276332
this.createSocket(req, options, (err, socket) => {
277-
if (err)
333+
if (err) {
334+
handleSocketAfterProxy(err, req);
335+
debug('call onSocket', sockLen, freeLen);
278336
req.onSocket(socket, err);
279-
else
280-
setRequestSocket(this, req, socket);
337+
return;
338+
}
339+
340+
setRequestSocket(this, req, socket);
281341
});
282342
} else {
283343
debug('wait for socket');
@@ -294,16 +354,23 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */,
294354
};
295355

296356
Agent.prototype.createSocket = function createSocket(req, options, cb) {
357+
// XXX: here the agent options will override per-request options.
297358
options = { __proto__: null, ...options, ...this.options };
298359
if (options.socketPath)
299360
options.path = options.socketPath;
300361

301362
normalizeServerName(options, req);
302363

364+
// Make sure per-request timeout is respected.
365+
const timeout = req.timeout || this.options.timeout || undefined;
366+
if (timeout) {
367+
options.timeout = timeout;
368+
}
369+
303370
const name = this.getName(options);
304371
options._agentKey = name;
305372

306-
debug('createConnection', name, options);
373+
debug('createConnection', name);
307374
options.encoding = null;
308375

309376
const oncreate = once((err, s) => {
@@ -321,8 +388,15 @@ Agent.prototype.createSocket = function createSocket(req, options, cb) {
321388
options.keepAlive = this.keepAlive;
322389
options.keepAliveInitialDelay = this.keepAliveMsecs;
323390
}
391+
324392
const newSocket = this.createConnection(options, oncreate);
325-
if (newSocket)
393+
// In the case where we are proxying through a tunnel for HTTPS, only add
394+
// the socket to the pool and install/invoke the listeners after
395+
// the tunnel is successfully established, so that actual operations
396+
// on the socket all go through the tunnel. Errors emitted during
397+
// tunnel establishment will be handled in the createConnection method
398+
// in lib/https.js.
399+
if (newSocket && !newSocket[kWaitForProxyTunnel])
326400
oncreate(null, newSocket);
327401
};
328402

@@ -456,10 +530,13 @@ Agent.prototype.removeSocket = function removeSocket(s, options) {
456530
req[kRequestOptions] = undefined;
457531
// If we have pending requests and a socket gets closed make a new one
458532
this.createSocket(req, options, (err, socket) => {
459-
if (err)
460-
req.onSocket(socket, err);
461-
else
462-
socket.emit('free');
533+
if (err) {
534+
handleSocketAfterProxy(err, req);
535+
req.onSocket(null, err);
536+
return;
537+
}
538+
539+
socket.emit('free');
463540
});
464541
}
465542

@@ -543,5 +620,8 @@ function asyncResetHandle(socket) {
543620

544621
module.exports = {
545622
Agent,
546-
globalAgent: new Agent({ keepAlive: true, scheduling: 'lifo', timeout: 5000 }),
623+
globalAgent: new Agent({
624+
keepAlive: true, scheduling: 'lifo', timeout: 5000,
625+
proxyEnv: process.env.NODE_USE_ENV_PROXY ? process.env : undefined,
626+
}),
547627
};

0 commit comments

Comments
 (0)