@openclaw/proxyline 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/README.md +25 -9
- package/dist/connect.d.ts +1 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +30 -1
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +202 -0
- package/dist/index.d.ts +4 -50
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -521
- package/dist/node-http.d.ts +52 -0
- package/dist/node-http.d.ts.map +1 -0
- package/dist/node-http.js +702 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +512 -0
- package/dist/shared.d.ts +1 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +3 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +17 -14
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import tls from "node:tls";
|
|
5
|
+
import { domainToASCII } from "node:url";
|
|
6
|
+
import { readProxyEnv, resolveAmbientProxyForUrl, } from "./env.js";
|
|
7
|
+
import { formatConnectAuthority } from "./connect.js";
|
|
8
|
+
import { ProxylineError, resolveProxyTlsCa } from "./shared.js";
|
|
9
|
+
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
10
|
+
const INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
|
|
11
|
+
const INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN = /[\u0000-\u0020\u007f]/;
|
|
12
|
+
const nodeAgentDefaultPorts = new WeakMap();
|
|
13
|
+
export const CALLER_AGENT_TLS_OPTION_KEYS = [
|
|
14
|
+
"ca",
|
|
15
|
+
"cert",
|
|
16
|
+
"ciphers",
|
|
17
|
+
"clientCertEngine",
|
|
18
|
+
"crl",
|
|
19
|
+
"dhparam",
|
|
20
|
+
"ecdhCurve",
|
|
21
|
+
"honorCipherOrder",
|
|
22
|
+
"key",
|
|
23
|
+
"maxVersion",
|
|
24
|
+
"minVersion",
|
|
25
|
+
"passphrase",
|
|
26
|
+
"pfx",
|
|
27
|
+
"rejectUnauthorized",
|
|
28
|
+
"secureOptions",
|
|
29
|
+
"secureProtocol",
|
|
30
|
+
"sessionIdContext",
|
|
31
|
+
];
|
|
32
|
+
function copyNodeHttpOptions(value) {
|
|
33
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
return { ...value };
|
|
37
|
+
}
|
|
38
|
+
function readAgentOptions(agent) {
|
|
39
|
+
if (agent === undefined || agent === false) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return agent.options;
|
|
43
|
+
}
|
|
44
|
+
function preserveCallerAgentOptions(options) {
|
|
45
|
+
const agentOptions = readAgentOptions(options.agent);
|
|
46
|
+
if (agentOptions === undefined) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const key of CALLER_AGENT_TLS_OPTION_KEYS) {
|
|
50
|
+
const value = agentOptions[key];
|
|
51
|
+
if (value !== undefined && options[key] === undefined) {
|
|
52
|
+
options[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function inferDestinationHostname(url, options) {
|
|
57
|
+
if (url !== undefined) {
|
|
58
|
+
return url instanceof URL ? url.hostname : new URL(url).hostname;
|
|
59
|
+
}
|
|
60
|
+
if (typeof options.hostname === "string") {
|
|
61
|
+
return options.hostname;
|
|
62
|
+
}
|
|
63
|
+
if (typeof options.host === "string") {
|
|
64
|
+
return options.host.replace(/:\d*$/, "");
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function preserveDestinationTlsIdentity(url, options) {
|
|
69
|
+
if (options.servername !== undefined) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const hostname = inferDestinationHostname(url, options);
|
|
73
|
+
if (!hostname) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (net.isIP(hostname) === 0) {
|
|
77
|
+
options.servername = hostname;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function bindNodeHttpMethod(originalMethod, createAgent) {
|
|
81
|
+
return ((...args) => {
|
|
82
|
+
let url;
|
|
83
|
+
let options;
|
|
84
|
+
let callback;
|
|
85
|
+
const firstArg = args[0];
|
|
86
|
+
if (typeof firstArg === "string" || firstArg instanceof URL) {
|
|
87
|
+
url = firstArg;
|
|
88
|
+
if (typeof args[1] === "function") {
|
|
89
|
+
options = {};
|
|
90
|
+
callback = args[1];
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
options = copyNodeHttpOptions(args[1]);
|
|
94
|
+
callback = args[2];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
options = copyNodeHttpOptions(firstArg);
|
|
99
|
+
callback = args[1];
|
|
100
|
+
}
|
|
101
|
+
preserveCallerAgentOptions(options);
|
|
102
|
+
preserveDestinationTlsIdentity(url, options);
|
|
103
|
+
const agent = createAgent(options);
|
|
104
|
+
options.agent = agent;
|
|
105
|
+
delete options.createConnection;
|
|
106
|
+
if (url !== undefined) {
|
|
107
|
+
const request = originalMethod(url, options, callback);
|
|
108
|
+
request.once("close", () => {
|
|
109
|
+
agent.destroy();
|
|
110
|
+
});
|
|
111
|
+
return request;
|
|
112
|
+
}
|
|
113
|
+
const request = originalMethod(options, callback);
|
|
114
|
+
request.once("close", () => {
|
|
115
|
+
agent.destroy();
|
|
116
|
+
});
|
|
117
|
+
return request;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function proxyHost(proxy) {
|
|
121
|
+
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
|
|
122
|
+
}
|
|
123
|
+
function proxyPort(proxy) {
|
|
124
|
+
if (proxy.port) {
|
|
125
|
+
return Number(proxy.port);
|
|
126
|
+
}
|
|
127
|
+
return proxy.protocol === "https:" ? 443 : 80;
|
|
128
|
+
}
|
|
129
|
+
function proxyAuthorization(proxy) {
|
|
130
|
+
if (!proxy.username && !proxy.password) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const username = decodeURIComponent(proxy.username);
|
|
134
|
+
const password = decodeURIComponent(proxy.password);
|
|
135
|
+
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
136
|
+
}
|
|
137
|
+
function proxyConnectOptions(proxy, proxyTls) {
|
|
138
|
+
const host = proxyHost(proxy);
|
|
139
|
+
const base = {
|
|
140
|
+
host,
|
|
141
|
+
port: proxyPort(proxy),
|
|
142
|
+
};
|
|
143
|
+
if (proxy.protocol !== "https:") {
|
|
144
|
+
return base;
|
|
145
|
+
}
|
|
146
|
+
const ca = resolveProxyTlsCa(proxyTls);
|
|
147
|
+
return {
|
|
148
|
+
...base,
|
|
149
|
+
ALPNProtocols: ["http/1.1"],
|
|
150
|
+
...(net.isIP(host) === 0 ? { servername: host } : {}),
|
|
151
|
+
...(ca !== undefined ? { ca } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function assertSupportedNodeProxyProtocol(proxy) {
|
|
155
|
+
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
|
|
156
|
+
throw new ProxylineError("UNSUPPORTED_PROXY_PROTOCOL", `Node HTTP agents support http:// and https:// proxy endpoints: ${proxy.protocol}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function requestProtocol(req, options, stackProtocol) {
|
|
160
|
+
const isWebSocket = isWebSocketRequest(req);
|
|
161
|
+
if (isSecureEndpoint(options, stackProtocol)) {
|
|
162
|
+
return isWebSocket ? "wss:" : "https:";
|
|
163
|
+
}
|
|
164
|
+
return isWebSocket ? "ws:" : "http:";
|
|
165
|
+
}
|
|
166
|
+
function normalizedPort(value) {
|
|
167
|
+
if (typeof value !== "number" && typeof value !== "string") {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const port = Number(value);
|
|
171
|
+
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : undefined;
|
|
172
|
+
}
|
|
173
|
+
function normalizedPositiveInteger(value) {
|
|
174
|
+
if (typeof value !== "number" && typeof value !== "string") {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const integer = Number(value);
|
|
178
|
+
return Number.isInteger(integer) && integer > 0 ? integer : undefined;
|
|
179
|
+
}
|
|
180
|
+
function requestAuthority(options) {
|
|
181
|
+
const rawHost = options.hostname ?? options.host ?? "localhost";
|
|
182
|
+
const parsed = splitHostPort(String(rawHost));
|
|
183
|
+
const host = normalizeProxyTargetHost(parsed.host || "localhost");
|
|
184
|
+
const port = parsed.port ?? normalizedPort(options.port);
|
|
185
|
+
const authorityHost = net.isIPv6(host) ? `[${host}]` : host;
|
|
186
|
+
return port === undefined ? authorityHost : `${authorityHost}:${port}`;
|
|
187
|
+
}
|
|
188
|
+
function requestDestinationUrl(req, options, stackProtocol) {
|
|
189
|
+
const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
|
|
190
|
+
return `${requestProtocol(req, options, stackProtocol)}//${requestAuthority(options)}${path}`;
|
|
191
|
+
}
|
|
192
|
+
function proxyForwardRequestPath(req, options) {
|
|
193
|
+
if (/^(?:https?|wss?):\/\//i.test(req.path)) {
|
|
194
|
+
return new URL(req.path).href;
|
|
195
|
+
}
|
|
196
|
+
return requestDestinationUrl(req, options, undefined);
|
|
197
|
+
}
|
|
198
|
+
function setProxyRequestHeaders(req, proxy, keepAlive) {
|
|
199
|
+
const authorization = proxyAuthorization(proxy);
|
|
200
|
+
if (authorization !== undefined) {
|
|
201
|
+
req.setHeader("Proxy-Authorization", authorization);
|
|
202
|
+
}
|
|
203
|
+
if (!req.hasHeader("Proxy-Connection")) {
|
|
204
|
+
req.setHeader("Proxy-Connection", keepAlive ? "Keep-Alive" : "close");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function setForwardProxyRequestPath(req, options) {
|
|
208
|
+
req._header = null;
|
|
209
|
+
req.path = proxyForwardRequestPath(req, options);
|
|
210
|
+
}
|
|
211
|
+
function connectToProxy(proxy, proxyTls) {
|
|
212
|
+
const options = proxyConnectOptions(proxy, proxyTls);
|
|
213
|
+
return proxy.protocol === "https:"
|
|
214
|
+
? tls.connect(options)
|
|
215
|
+
: net.connect(options);
|
|
216
|
+
}
|
|
217
|
+
function isWebSocketRequest(req) {
|
|
218
|
+
return String(req.getHeader("upgrade") ?? "").toLowerCase() === "websocket";
|
|
219
|
+
}
|
|
220
|
+
function isSecureEndpoint(options, stackProtocol) {
|
|
221
|
+
return (stackProtocol === "https" ||
|
|
222
|
+
options.secureEndpoint === true ||
|
|
223
|
+
options.protocol === "https:" ||
|
|
224
|
+
options.protocol === "wss:" ||
|
|
225
|
+
options.defaultPort === 443);
|
|
226
|
+
}
|
|
227
|
+
function shouldTunnelRequest(req, options, stackProtocol) {
|
|
228
|
+
return isSecureEndpoint(options, stackProtocol) || isWebSocketRequest(req);
|
|
229
|
+
}
|
|
230
|
+
function splitHostPort(value) {
|
|
231
|
+
const bracketed = value.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
232
|
+
if (bracketed) {
|
|
233
|
+
return {
|
|
234
|
+
host: bracketed[1] ?? "",
|
|
235
|
+
...(bracketed[2] !== undefined ? { port: Number(bracketed[2]) } : {}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const lastColon = value.lastIndexOf(":");
|
|
239
|
+
const hasSingleColon = lastColon !== -1 && value.indexOf(":") === lastColon;
|
|
240
|
+
if (hasSingleColon) {
|
|
241
|
+
const possiblePort = value.slice(lastColon + 1);
|
|
242
|
+
if (/^\d+$/.test(possiblePort)) {
|
|
243
|
+
return { host: value.slice(0, lastColon), port: Number(possiblePort) };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { host: value };
|
|
247
|
+
}
|
|
248
|
+
function normalizeProxyTargetHost(host) {
|
|
249
|
+
const unbracketedHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
250
|
+
if (net.isIP(unbracketedHost) !== 0) {
|
|
251
|
+
return unbracketedHost;
|
|
252
|
+
}
|
|
253
|
+
if (INVALID_PROXY_TARGET_HOST_DELIMITER_PATTERN.test(host)) {
|
|
254
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host contains unsafe delimiters.");
|
|
255
|
+
}
|
|
256
|
+
if (INVALID_PROXY_TARGET_HOST_CONTROL_PATTERN.test(host)) {
|
|
257
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host contains unsafe characters.");
|
|
258
|
+
}
|
|
259
|
+
const asciiHost = domainToASCII(host);
|
|
260
|
+
if (!asciiHost) {
|
|
261
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is not a valid host name.");
|
|
262
|
+
}
|
|
263
|
+
return asciiHost;
|
|
264
|
+
}
|
|
265
|
+
function connectTarget(options) {
|
|
266
|
+
const rawHost = options.hostname ?? options.host;
|
|
267
|
+
if (typeof rawHost !== "string") {
|
|
268
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target is missing host.");
|
|
269
|
+
}
|
|
270
|
+
const parsed = splitHostPort(rawHost);
|
|
271
|
+
const port = parsed.port ?? Number(options.port);
|
|
272
|
+
if (!parsed.host || !Number.isInteger(port)) {
|
|
273
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target is missing host or port.");
|
|
274
|
+
}
|
|
275
|
+
const host = normalizeProxyTargetHost(parsed.host);
|
|
276
|
+
formatConnectAuthority(host, port);
|
|
277
|
+
return { host, port };
|
|
278
|
+
}
|
|
279
|
+
function destinationTlsConnectOptions(options, socket) {
|
|
280
|
+
const target = connectTarget(options);
|
|
281
|
+
const tlsOptions = { ...options, socket };
|
|
282
|
+
tlsOptions.host = target.host;
|
|
283
|
+
delete tlsOptions.path;
|
|
284
|
+
delete tlsOptions.port;
|
|
285
|
+
delete tlsOptions.secureEndpoint;
|
|
286
|
+
delete tlsOptions.agent;
|
|
287
|
+
return tlsOptions;
|
|
288
|
+
}
|
|
289
|
+
class ProxylineHttpForwardAgent extends http.Agent {
|
|
290
|
+
options;
|
|
291
|
+
#keepAlive;
|
|
292
|
+
#proxy;
|
|
293
|
+
#proxyTls;
|
|
294
|
+
constructor(proxy, options, proxyTls) {
|
|
295
|
+
super(options);
|
|
296
|
+
this.options = options;
|
|
297
|
+
this.#keepAlive = options.keepAlive === true;
|
|
298
|
+
this.#proxy = proxy;
|
|
299
|
+
this.#proxyTls = proxyTls;
|
|
300
|
+
}
|
|
301
|
+
addRequest(req, options) {
|
|
302
|
+
setForwardProxyRequestPath(req, options);
|
|
303
|
+
setProxyRequestHeaders(req, this.#proxy, this.#keepAlive);
|
|
304
|
+
http.Agent.prototype.addRequest.call(this, req, options);
|
|
305
|
+
}
|
|
306
|
+
createConnection(_options, callback) {
|
|
307
|
+
const socket = connectToProxy(this.#proxy, this.#proxyTls);
|
|
308
|
+
if (callback !== undefined) {
|
|
309
|
+
const onError = (error) => {
|
|
310
|
+
callback(error, socket);
|
|
311
|
+
};
|
|
312
|
+
const onConnected = () => {
|
|
313
|
+
socket.off("error", onError);
|
|
314
|
+
callback(null, socket);
|
|
315
|
+
};
|
|
316
|
+
socket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
317
|
+
socket.once("error", onError);
|
|
318
|
+
}
|
|
319
|
+
return socket;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
class ProxylineConnectAgent extends http.Agent {
|
|
323
|
+
options;
|
|
324
|
+
#keepAlive;
|
|
325
|
+
#pendingConnectSockets = new Set();
|
|
326
|
+
#pendingRequests = new WeakMap();
|
|
327
|
+
#pendingRequestQueue = [];
|
|
328
|
+
#proxy;
|
|
329
|
+
#proxyTls;
|
|
330
|
+
constructor(proxy, options, proxyTls) {
|
|
331
|
+
super(options);
|
|
332
|
+
this.options = options;
|
|
333
|
+
this.#keepAlive = options.keepAlive === true;
|
|
334
|
+
this.#proxy = proxy;
|
|
335
|
+
this.#proxyTls = proxyTls;
|
|
336
|
+
}
|
|
337
|
+
addRequest(req, options) {
|
|
338
|
+
this.#pendingRequests.set(options, req);
|
|
339
|
+
this.#pendingRequestQueue.push(req);
|
|
340
|
+
req.once("socket", () => this.#removePendingRequest(req));
|
|
341
|
+
req.once("close", () => this.#removePendingRequest(req));
|
|
342
|
+
try {
|
|
343
|
+
http.Agent.prototype.addRequest.call(this, req, options);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
this.#pendingRequests.delete(options);
|
|
347
|
+
this.#removePendingRequest(req);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
#removePendingRequest(req) {
|
|
352
|
+
const index = this.#pendingRequestQueue.indexOf(req);
|
|
353
|
+
if (index !== -1) {
|
|
354
|
+
this.#pendingRequestQueue.splice(index, 1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
createConnection(options, callback) {
|
|
358
|
+
const mappedRequest = this.#pendingRequests.get(options);
|
|
359
|
+
const request = mappedRequest ?? this.#pendingRequestQueue.shift();
|
|
360
|
+
this.#pendingRequests.delete(options);
|
|
361
|
+
if (mappedRequest !== undefined) {
|
|
362
|
+
this.#removePendingRequest(mappedRequest);
|
|
363
|
+
}
|
|
364
|
+
if (callback === undefined) {
|
|
365
|
+
throw new ProxylineError("INVALID_CONNECT_CALLBACK", "CONNECT agents require an async socket callback.");
|
|
366
|
+
}
|
|
367
|
+
const proxySocket = connectToProxy(this.#proxy, this.#proxyTls);
|
|
368
|
+
this.#pendingConnectSockets.add(proxySocket);
|
|
369
|
+
let pendingTimeout;
|
|
370
|
+
let settled = false;
|
|
371
|
+
let responseBuffer = Buffer.alloc(0);
|
|
372
|
+
let originalRequestSetTimeout;
|
|
373
|
+
let hookedRequestSetTimeout;
|
|
374
|
+
let tlsSocket;
|
|
375
|
+
const startPendingTimeout = (timeoutMs) => {
|
|
376
|
+
if (pendingTimeout !== undefined) {
|
|
377
|
+
clearTimeout(pendingTimeout);
|
|
378
|
+
}
|
|
379
|
+
pendingTimeout = setTimeout(() => {
|
|
380
|
+
request?.emit("timeout");
|
|
381
|
+
if (!settled) {
|
|
382
|
+
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT timed out"));
|
|
383
|
+
}
|
|
384
|
+
}, timeoutMs);
|
|
385
|
+
pendingTimeout.unref?.();
|
|
386
|
+
};
|
|
387
|
+
const clearPendingTimeout = () => {
|
|
388
|
+
if (pendingTimeout !== undefined) {
|
|
389
|
+
clearTimeout(pendingTimeout);
|
|
390
|
+
pendingTimeout = undefined;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const restoreRequestTimeoutHook = () => {
|
|
394
|
+
if (request !== undefined &&
|
|
395
|
+
originalRequestSetTimeout !== undefined &&
|
|
396
|
+
request.setTimeout === hookedRequestSetTimeout) {
|
|
397
|
+
request.setTimeout = originalRequestSetTimeout;
|
|
398
|
+
}
|
|
399
|
+
originalRequestSetTimeout = undefined;
|
|
400
|
+
hookedRequestSetTimeout = undefined;
|
|
401
|
+
};
|
|
402
|
+
const cleanupProxyHandshakeListeners = () => {
|
|
403
|
+
proxySocket.off("data", onData);
|
|
404
|
+
proxySocket.off("error", onError);
|
|
405
|
+
proxySocket.off("end", onClosed);
|
|
406
|
+
proxySocket.off("close", onClosed);
|
|
407
|
+
proxySocket.off("connect", onConnected);
|
|
408
|
+
proxySocket.off("secureConnect", onConnected);
|
|
409
|
+
};
|
|
410
|
+
const cleanup = () => {
|
|
411
|
+
clearPendingTimeout();
|
|
412
|
+
this.#pendingConnectSockets.delete(proxySocket);
|
|
413
|
+
if (tlsSocket !== undefined) {
|
|
414
|
+
this.#pendingConnectSockets.delete(tlsSocket);
|
|
415
|
+
}
|
|
416
|
+
restoreRequestTimeoutHook();
|
|
417
|
+
cleanupProxyHandshakeListeners();
|
|
418
|
+
request?.off("abort", onRequestClosed);
|
|
419
|
+
request?.off("close", onRequestClosed);
|
|
420
|
+
request?.off("error", onRequestClosed);
|
|
421
|
+
request?.off("timeout", onRequestTimedOut);
|
|
422
|
+
};
|
|
423
|
+
const finish = (error, socket) => {
|
|
424
|
+
if (settled) {
|
|
425
|
+
if (error === null) {
|
|
426
|
+
socket.destroy();
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
settled = true;
|
|
431
|
+
cleanup();
|
|
432
|
+
callback(error, socket);
|
|
433
|
+
};
|
|
434
|
+
const fail = (error) => {
|
|
435
|
+
proxySocket.destroy();
|
|
436
|
+
finish(error, proxySocket);
|
|
437
|
+
};
|
|
438
|
+
const onConnected = () => {
|
|
439
|
+
let target;
|
|
440
|
+
try {
|
|
441
|
+
target = connectTarget(options);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const { host, port } = target;
|
|
448
|
+
const authority = formatConnectAuthority(host, port);
|
|
449
|
+
const headers = [
|
|
450
|
+
`CONNECT ${authority} HTTP/1.1`,
|
|
451
|
+
`Host: ${authority}`,
|
|
452
|
+
`Proxy-Connection: ${this.#keepAlive ? "Keep-Alive" : "close"}`,
|
|
453
|
+
];
|
|
454
|
+
const authorization = proxyAuthorization(this.#proxy);
|
|
455
|
+
if (authorization !== undefined) {
|
|
456
|
+
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
457
|
+
}
|
|
458
|
+
proxySocket.write([...headers, "", ""].join("\r\n"));
|
|
459
|
+
};
|
|
460
|
+
const onData = (chunk) => {
|
|
461
|
+
responseBuffer = Buffer.concat([responseBuffer, chunk]);
|
|
462
|
+
const headerEnd = responseBuffer.indexOf("\r\n\r\n");
|
|
463
|
+
if (headerEnd === -1) {
|
|
464
|
+
if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
|
465
|
+
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT response headers were too large"));
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const bodyOffset = headerEnd + 4;
|
|
470
|
+
if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
|
471
|
+
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT response headers were too large"));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const statusLine = responseBuffer.subarray(0, bodyOffset).toString("latin1").split("\r\n", 1)[0] ?? "";
|
|
475
|
+
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
|
|
476
|
+
fail(new ProxylineError("CONNECT_FAILED", statusLine || "proxy returned an invalid CONNECT response"));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const tunneledBytes = responseBuffer.subarray(bodyOffset);
|
|
480
|
+
cleanupProxyHandshakeListeners();
|
|
481
|
+
if (tunneledBytes.length > 0) {
|
|
482
|
+
proxySocket.unshift(tunneledBytes);
|
|
483
|
+
}
|
|
484
|
+
if (!isSecureEndpoint(options)) {
|
|
485
|
+
finish(null, proxySocket);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const currentTlsSocket = tls.connect(destinationTlsConnectOptions(options, proxySocket));
|
|
489
|
+
tlsSocket = currentTlsSocket;
|
|
490
|
+
this.#pendingConnectSockets.add(currentTlsSocket);
|
|
491
|
+
const onTlsError = (error) => {
|
|
492
|
+
currentTlsSocket.off("close", onTlsClosed);
|
|
493
|
+
finish(error, currentTlsSocket);
|
|
494
|
+
};
|
|
495
|
+
const onTlsSecureConnect = () => {
|
|
496
|
+
currentTlsSocket.off("error", onTlsError);
|
|
497
|
+
currentTlsSocket.off("close", onTlsClosed);
|
|
498
|
+
finish(null, currentTlsSocket);
|
|
499
|
+
};
|
|
500
|
+
const onTlsClosed = () => {
|
|
501
|
+
finish(new ProxylineError("CONNECT_FAILED", "destination TLS socket closed before secureConnect"), currentTlsSocket);
|
|
502
|
+
};
|
|
503
|
+
currentTlsSocket.once("secureConnect", onTlsSecureConnect);
|
|
504
|
+
currentTlsSocket.once("error", onTlsError);
|
|
505
|
+
currentTlsSocket.once("close", onTlsClosed);
|
|
506
|
+
};
|
|
507
|
+
const onError = (error) => {
|
|
508
|
+
fail(error);
|
|
509
|
+
};
|
|
510
|
+
const onClosed = () => {
|
|
511
|
+
fail(new ProxylineError("CONNECT_FAILED", "proxy socket closed before CONNECT completed"));
|
|
512
|
+
};
|
|
513
|
+
const onRequestClosed = () => {
|
|
514
|
+
if (!settled) {
|
|
515
|
+
fail(new ProxylineError("CONNECT_FAILED", "request closed before proxy CONNECT completed"));
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
const onRequestTimedOut = () => {
|
|
519
|
+
if (!settled) {
|
|
520
|
+
fail(new ProxylineError("CONNECT_FAILED", "proxy CONNECT timed out"));
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
if (request !== undefined) {
|
|
524
|
+
originalRequestSetTimeout = request.setTimeout;
|
|
525
|
+
hookedRequestSetTimeout = function hookedSetTimeout(timeout, callback) {
|
|
526
|
+
const result = originalRequestSetTimeout?.call(this, timeout, callback) ?? this;
|
|
527
|
+
const timeoutMs = normalizedPositiveInteger(timeout);
|
|
528
|
+
if (timeoutMs !== undefined) {
|
|
529
|
+
startPendingTimeout(timeoutMs);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
clearPendingTimeout();
|
|
533
|
+
}
|
|
534
|
+
return result;
|
|
535
|
+
};
|
|
536
|
+
request.setTimeout = hookedRequestSetTimeout;
|
|
537
|
+
}
|
|
538
|
+
const requestTimeout = request?.timeout;
|
|
539
|
+
const timeoutMs = normalizedPositiveInteger(options.timeout ?? requestTimeout);
|
|
540
|
+
if (timeoutMs !== undefined) {
|
|
541
|
+
startPendingTimeout(timeoutMs);
|
|
542
|
+
}
|
|
543
|
+
request?.once("abort", onRequestClosed);
|
|
544
|
+
request?.once("close", onRequestClosed);
|
|
545
|
+
request?.once("error", onRequestClosed);
|
|
546
|
+
request?.once("timeout", onRequestTimedOut);
|
|
547
|
+
proxySocket.once(this.#proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
548
|
+
proxySocket.on("data", onData);
|
|
549
|
+
proxySocket.once("error", onError);
|
|
550
|
+
proxySocket.once("end", onClosed);
|
|
551
|
+
proxySocket.once("close", onClosed);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
destroy() {
|
|
555
|
+
for (const socket of this.#pendingConnectSockets) {
|
|
556
|
+
socket.destroy();
|
|
557
|
+
}
|
|
558
|
+
this.#pendingConnectSockets.clear();
|
|
559
|
+
super.destroy();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
export class ProxylineNodeProxyAgent extends http.Agent {
|
|
563
|
+
options;
|
|
564
|
+
#agents = new Map();
|
|
565
|
+
#defaultProtocol;
|
|
566
|
+
#getProxyForUrl;
|
|
567
|
+
#httpAgent;
|
|
568
|
+
#httpsAgent;
|
|
569
|
+
#proxyTls;
|
|
570
|
+
constructor(options) {
|
|
571
|
+
const { defaultProtocol = "http", getProxyForUrl, proxyTls, ...agentOptions } = options;
|
|
572
|
+
super(agentOptions);
|
|
573
|
+
if (nodeAgentDefaultPorts.get(this) === 80) {
|
|
574
|
+
nodeAgentDefaultPorts.delete(this);
|
|
575
|
+
}
|
|
576
|
+
this.options = agentOptions;
|
|
577
|
+
this.#defaultProtocol = defaultProtocol;
|
|
578
|
+
this.#getProxyForUrl = getProxyForUrl;
|
|
579
|
+
this.#proxyTls = proxyTls;
|
|
580
|
+
this.#httpAgent = new http.Agent(agentOptions);
|
|
581
|
+
this.#httpsAgent = new https.Agent(agentOptions);
|
|
582
|
+
}
|
|
583
|
+
get defaultPort() {
|
|
584
|
+
const stackProtocol = this.#callStackProtocol();
|
|
585
|
+
return nodeAgentDefaultPorts.get(this) ??
|
|
586
|
+
((stackProtocol ?? this.#defaultProtocol) === "https" ? 443 : 80);
|
|
587
|
+
}
|
|
588
|
+
set defaultPort(value) {
|
|
589
|
+
nodeAgentDefaultPorts.set(this, value);
|
|
590
|
+
}
|
|
591
|
+
get protocol() {
|
|
592
|
+
return `${this.#callStackProtocol() ?? this.#defaultProtocol}:`;
|
|
593
|
+
}
|
|
594
|
+
set protocol(_value) {
|
|
595
|
+
// Node's http.Agent constructor assigns this, but this wrapper is dual-use.
|
|
596
|
+
}
|
|
597
|
+
getProxyForUrl(url, request) {
|
|
598
|
+
return this.#getProxyForUrl(url, request);
|
|
599
|
+
}
|
|
600
|
+
#callStackProtocol() {
|
|
601
|
+
const originalStackTraceLimit = Error.stackTraceLimit;
|
|
602
|
+
const errorConstructor = Error;
|
|
603
|
+
const originalPrepareStackTrace = errorConstructor.prepareStackTrace;
|
|
604
|
+
if (typeof originalStackTraceLimit !== "number" || originalStackTraceLimit < 20) {
|
|
605
|
+
// Node reads agent.protocol/defaultPort before addRequest, so this is the only caller signal.
|
|
606
|
+
Error.stackTraceLimit = 20;
|
|
607
|
+
}
|
|
608
|
+
let stack;
|
|
609
|
+
try {
|
|
610
|
+
delete errorConstructor.prepareStackTrace;
|
|
611
|
+
stack = new Error().stack;
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
if (originalPrepareStackTrace === undefined) {
|
|
615
|
+
delete errorConstructor.prepareStackTrace;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
errorConstructor.prepareStackTrace = originalPrepareStackTrace;
|
|
619
|
+
}
|
|
620
|
+
Error.stackTraceLimit = originalStackTraceLimit;
|
|
621
|
+
}
|
|
622
|
+
if (typeof stack !== "string") {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
for (const line of stack.split("\n")) {
|
|
626
|
+
if (line.includes("node:https:")) {
|
|
627
|
+
return "https";
|
|
628
|
+
}
|
|
629
|
+
if (line.includes("node:http:")) {
|
|
630
|
+
return "http";
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
addRequest(req, options) {
|
|
636
|
+
const stackProtocol = this.#callStackProtocol();
|
|
637
|
+
const agentOptions = stackProtocol === "https" && options.secureEndpoint !== true
|
|
638
|
+
? { ...options, secureEndpoint: true }
|
|
639
|
+
: options;
|
|
640
|
+
const url = requestDestinationUrl(req, agentOptions, stackProtocol);
|
|
641
|
+
const proxy = this.#getProxyForUrl(url, req);
|
|
642
|
+
if (!proxy) {
|
|
643
|
+
(isSecureEndpoint(agentOptions, stackProtocol) ? this.#httpsAgent : this.#httpAgent)
|
|
644
|
+
.addRequest(req, agentOptions);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const proxyUrl = new URL(proxy);
|
|
648
|
+
assertSupportedNodeProxyProtocol(proxyUrl);
|
|
649
|
+
const tunnel = shouldTunnelRequest(req, agentOptions, stackProtocol);
|
|
650
|
+
const key = `${tunnel ? "connect" : "forward"}:${proxyUrl.href}`;
|
|
651
|
+
let agent = this.#agents.get(key);
|
|
652
|
+
if (agent === undefined) {
|
|
653
|
+
const newAgent = tunnel
|
|
654
|
+
? new ProxylineConnectAgent(proxyUrl, this.options, this.#proxyTls)
|
|
655
|
+
: new ProxylineHttpForwardAgent(proxyUrl, this.options, this.#proxyTls);
|
|
656
|
+
agent = newAgent;
|
|
657
|
+
this.#agents.set(key, agent);
|
|
658
|
+
}
|
|
659
|
+
agent.addRequest(req, agentOptions);
|
|
660
|
+
}
|
|
661
|
+
destroy() {
|
|
662
|
+
for (const agent of this.#agents.values()) {
|
|
663
|
+
agent.destroy();
|
|
664
|
+
}
|
|
665
|
+
this.#agents.clear();
|
|
666
|
+
this.#httpAgent.destroy();
|
|
667
|
+
this.#httpsAgent.destroy();
|
|
668
|
+
super.destroy();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
export function createNodeProxyAgent(resolver, proxyCa, defaultProtocol = "http") {
|
|
672
|
+
return new ProxylineNodeProxyAgent({
|
|
673
|
+
defaultProtocol,
|
|
674
|
+
getProxyForUrl: resolver.getProxyForUrl,
|
|
675
|
+
...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
export function createDirectNodeAgent() {
|
|
679
|
+
return new ProxylineNodeProxyAgent({
|
|
680
|
+
getProxyForUrl: () => "",
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
function ambientProbeUrl(protocol) {
|
|
684
|
+
return `${protocol}://proxyline.invalid/`;
|
|
685
|
+
}
|
|
686
|
+
export function hasAmbientNodeProxyConfigured(options = {}) {
|
|
687
|
+
const env = options.env ?? readProxyEnv();
|
|
688
|
+
const protocol = options.protocol ?? "https";
|
|
689
|
+
return resolveAmbientProxyForUrl(ambientProbeUrl(protocol), env) !== undefined;
|
|
690
|
+
}
|
|
691
|
+
export function createAmbientNodeProxyAgent(options = {}) {
|
|
692
|
+
const env = options.env ?? readProxyEnv();
|
|
693
|
+
const protocol = options.protocol ?? "https";
|
|
694
|
+
if (resolveAmbientProxyForUrl(ambientProbeUrl(protocol), env) === undefined) {
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
return new ProxylineNodeProxyAgent({
|
|
698
|
+
defaultProtocol: protocol,
|
|
699
|
+
getProxyForUrl: (url) => resolveAmbientProxyForUrl(url, env) ?? "",
|
|
700
|
+
...(options.proxyTls !== undefined ? { proxyTls: options.proxyTls } : {}),
|
|
701
|
+
});
|
|
702
|
+
}
|