tunnelfetch 1.0.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/LICENSE +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
package/src/pool.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Connection pool for HTTP/1.1 keep-alive.
|
|
2
|
+
//
|
|
3
|
+
// Scope, stated up front because it is unusual: on the target runtime a socket cannot outlive the
|
|
4
|
+
// request that created it — I/O objects do not cross request contexts — so this pool is
|
|
5
|
+
// per-Client and deliberately not a global. The win it exists for is the crawl-shaped workload:
|
|
6
|
+
// one invocation fetching thirty pages from one host pays one TLS handshake instead of thirty,
|
|
7
|
+
// and a userland handshake is the single most expensive thing this package does.
|
|
8
|
+
//
|
|
9
|
+
// The rule that makes reuse safe, and the one that is worth getting paranoid about:
|
|
10
|
+
//
|
|
11
|
+
// A connection returns to the pool ONLY after its response body has been read to the exact end
|
|
12
|
+
// the framing declared. Not "the caller stopped reading", not "it looked finished" — the body's
|
|
13
|
+
// own completion signal. Handing back a socket with unread bytes on it means the next request
|
|
14
|
+
// reads the tail of the previous response and attributes it to the wrong request, which is the
|
|
15
|
+
// worst class of bug an HTTP client can have: it corrupts data silently, under load, and only
|
|
16
|
+
// sometimes.
|
|
17
|
+
//
|
|
18
|
+
// The second rule follows from the first: a body framed by connection close has no determinate
|
|
19
|
+
// end, so such a connection is never eligible, whatever else is true of it.
|
|
20
|
+
|
|
21
|
+
import { TunnelFetchError, codes } from './errors.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Everything that decides whether two requests may share a socket. Mirrors what openConnection
|
|
25
|
+
* consumed to build the connection, because anything that influenced the connection must
|
|
26
|
+
* influence the key.
|
|
27
|
+
* @typedef {object} PoolKeyInput
|
|
28
|
+
* @property {string} scheme the URL protocol, colon included ('http:' | 'https:')
|
|
29
|
+
* @property {string} hostname
|
|
30
|
+
* @property {number} port
|
|
31
|
+
* @property {import('./proxy/index.js').ProxyConfig | null | undefined} proxy
|
|
32
|
+
* @property {import('./trust/index.js').TrustConfig | null | undefined} trust
|
|
33
|
+
* @property {import('./tls/connect.js').TlsOptions | null | undefined} tls
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The trust configuration is part of the key. Two requests to the same origin under different
|
|
38
|
+
* verification policies must not share a connection — the peer was validated under one policy and
|
|
39
|
+
* silently reusing it satisfies the other policy without ever having checked it.
|
|
40
|
+
*
|
|
41
|
+
* @param {PoolKeyInput} input
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function poolKey({ scheme, hostname, port, proxy, trust, tls }) {
|
|
45
|
+
const proxyPart = proxy
|
|
46
|
+
? `${proxy.protocol}://${proxy.username ? `${proxy.username}@` : ''}${proxy.hostname}:${proxy.port}`
|
|
47
|
+
: 'direct';
|
|
48
|
+
const trustPart = trustFingerprint(trust);
|
|
49
|
+
const tlsPart = tls && Object.keys(tls).length ? JSON.stringify(sortedEntries(tls)) : '-';
|
|
50
|
+
return `${scheme}//${hostname}:${port}|${proxyPart}|${trustPart}|${tlsPart}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function trustFingerprint(trust) {
|
|
54
|
+
const mode = trust?.mode ?? 'system';
|
|
55
|
+
if (mode === 'system') return 'system';
|
|
56
|
+
if (mode === 'none') return 'none';
|
|
57
|
+
if (mode === 'custom') {
|
|
58
|
+
// Two different callbacks are two different policies and we cannot compare functions, so a
|
|
59
|
+
// custom policy never shares a connection. Correct, and cheap: custom trust is rare.
|
|
60
|
+
return `custom:${customCounter(trust.verify)}`;
|
|
61
|
+
}
|
|
62
|
+
if (mode === 'pinned') return `pinned:${[...(trust.pins ?? [])].sort().join(',')}`;
|
|
63
|
+
if (mode === 'anchors') return `anchors:${(trust.anchors ?? []).length}:${anchorDigest(trust.anchors)}`;
|
|
64
|
+
return `unknown:${mode}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const customIds = new WeakMap();
|
|
68
|
+
let customSeq = 0;
|
|
69
|
+
function customCounter(fn) {
|
|
70
|
+
if (typeof fn !== 'function') return 'invalid';
|
|
71
|
+
let id = customIds.get(fn);
|
|
72
|
+
if (id === undefined) {
|
|
73
|
+
id = ++customSeq;
|
|
74
|
+
customIds.set(fn, id);
|
|
75
|
+
}
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const anchorDigests = new WeakMap();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Order-sensitive digest of anchor material.
|
|
83
|
+
*
|
|
84
|
+
* Every byte is hashed. An earlier version sampled every 97th byte to save work and two anchor
|
|
85
|
+
* sets of equal length that differed only in the middle produced the same key — which would have
|
|
86
|
+
* let a connection validated against one anchor set serve a request that asked for a different
|
|
87
|
+
* one. Cheap-and-approximate is the wrong trade when the output decides whether two security
|
|
88
|
+
* policies are the same policy. The result is memoised per array, so the cost is paid once.
|
|
89
|
+
*/
|
|
90
|
+
function anchorDigest(anchors = []) {
|
|
91
|
+
const cached = typeof anchors === 'object' ? anchorDigests.get(anchors) : undefined;
|
|
92
|
+
if (cached !== undefined) return cached;
|
|
93
|
+
|
|
94
|
+
let h = 0x811c9dc5;
|
|
95
|
+
const mix = (byte) => {
|
|
96
|
+
h = Math.imul(h ^ byte, 0x01000193) >>> 0;
|
|
97
|
+
};
|
|
98
|
+
for (const a of anchors) {
|
|
99
|
+
const isText = typeof a === 'string';
|
|
100
|
+
const len = isText ? a.length : (a?.byteLength ?? 0);
|
|
101
|
+
// Length is folded in separately so concatenation cannot forge an equal digest.
|
|
102
|
+
mix(len & 0xff);
|
|
103
|
+
mix((len >>> 8) & 0xff);
|
|
104
|
+
mix((len >>> 16) & 0xff);
|
|
105
|
+
if (isText) for (let i = 0; i < len; i++) mix(a.charCodeAt(i) & 0xff);
|
|
106
|
+
else for (let i = 0; i < len; i++) mix(a[i]);
|
|
107
|
+
mix(0xff); // separator, so ['ab','c'] and ['a','bc'] differ
|
|
108
|
+
}
|
|
109
|
+
const out = h.toString(16);
|
|
110
|
+
if (typeof anchors === 'object') anchorDigests.set(anchors, out);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sortedEntries = (o) => Object.entries(o).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What the pool stores: the connection object openConnection resolves to. The pool itself only
|
|
118
|
+
* ever calls `close?.()`, but naming the real type keeps take() useful to a caller.
|
|
119
|
+
* @typedef {import('./transport.js').Connection} PooledConnection
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @typedef {object} PoolOptions
|
|
124
|
+
* @property {number} [maxPerKey] idle connections kept per key, default 6
|
|
125
|
+
* @property {number} [maxTotal] idle connections kept across all keys, default 24
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Running counters, never reset. `discarded` includes connections refused at release time;
|
|
130
|
+
* `evicted` counts victims pushed out by a newer release under a full pool.
|
|
131
|
+
* @typedef {object} PoolStats
|
|
132
|
+
* @property {number} hits
|
|
133
|
+
* @property {number} misses
|
|
134
|
+
* @property {number} released
|
|
135
|
+
* @property {number} discarded
|
|
136
|
+
* @property {number} evicted
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
export class ConnectionPool {
|
|
140
|
+
/**
|
|
141
|
+
* @param {PoolOptions} [opts]
|
|
142
|
+
*/
|
|
143
|
+
constructor({ maxPerKey = 6, maxTotal = 24 } = {}) {
|
|
144
|
+
/** @type {Map<string, Array<{conn: PooledConnection}>>} */
|
|
145
|
+
this._idle = new Map();
|
|
146
|
+
this._total = 0;
|
|
147
|
+
this._maxPerKey = maxPerKey;
|
|
148
|
+
this._maxTotal = maxTotal;
|
|
149
|
+
this._closed = false;
|
|
150
|
+
/** @type {PoolStats} */
|
|
151
|
+
this.stats = { hits: 0, misses: 0, released: 0, discarded: 0, evicted: 0 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get idleCount() {
|
|
155
|
+
return this._total;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Take an idle connection for `key`, or null. Most-recently-used first: it is likeliest live.
|
|
160
|
+
* @param {string} key
|
|
161
|
+
* @returns {PooledConnection | null}
|
|
162
|
+
*/
|
|
163
|
+
take(key) {
|
|
164
|
+
this._assertOpen();
|
|
165
|
+
const list = this._idle.get(key);
|
|
166
|
+
if (!list || list.length === 0) {
|
|
167
|
+
this.stats.misses++;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const entry = list.pop();
|
|
171
|
+
if (list.length === 0) this._idle.delete(key);
|
|
172
|
+
this._total--;
|
|
173
|
+
this.stats.hits++;
|
|
174
|
+
return entry.conn;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Offer a connection back. Callers must have proven the body reached its declared end; this
|
|
179
|
+
* method cannot verify that and deliberately does not pretend to — `eligible` is the caller's
|
|
180
|
+
* assertion, and the one place it is computed is the HTTP framing layer.
|
|
181
|
+
* Idle entries carry no age, deliberately. Ageing them out would only narrow the window in
|
|
182
|
+
* which a peer reaps a socket we still believe in, never close it — the peer can hang up at any
|
|
183
|
+
* instant, including the one after the check. What actually makes reuse safe is the recovery in
|
|
184
|
+
* sendAndReceive(): a reused connection that ends without producing one response byte is proof
|
|
185
|
+
* the request was never seen, and it is re-sent on a fresh connection. An age field would look
|
|
186
|
+
* like a second line of defence while being neither necessary nor sufficient.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} key
|
|
189
|
+
* @param {PooledConnection} conn
|
|
190
|
+
* @param {boolean} eligible
|
|
191
|
+
* @returns {boolean} whether the connection was retained
|
|
192
|
+
*/
|
|
193
|
+
release(key, conn, eligible) {
|
|
194
|
+
if (this._closed || !eligible) {
|
|
195
|
+
this.stats.discarded++;
|
|
196
|
+
void discard(conn);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const list = this._idle.get(key) ?? [];
|
|
200
|
+
if (list.length >= this._maxPerKey || this._total >= this._maxTotal) {
|
|
201
|
+
// Drop the oldest rather than refusing the newest: the newest is the one just proven alive.
|
|
202
|
+
const victim = list.shift() ?? null;
|
|
203
|
+
if (victim) {
|
|
204
|
+
this._total--;
|
|
205
|
+
this.stats.evicted++;
|
|
206
|
+
void discard(victim.conn);
|
|
207
|
+
} else {
|
|
208
|
+
this.stats.discarded++;
|
|
209
|
+
void discard(conn);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
list.push({ conn });
|
|
214
|
+
this._idle.set(key, list);
|
|
215
|
+
this._total++;
|
|
216
|
+
this.stats.released++;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Close and forget one connection that must not be reused.
|
|
222
|
+
* @param {PooledConnection} conn
|
|
223
|
+
* @returns {Promise<void>}
|
|
224
|
+
*/
|
|
225
|
+
discard(conn) {
|
|
226
|
+
this.stats.discarded++;
|
|
227
|
+
return discard(conn);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Close everything. A Client that is done must call this or sockets leak for the isolate.
|
|
232
|
+
* @returns {Promise<void>}
|
|
233
|
+
*/
|
|
234
|
+
async closeAll() {
|
|
235
|
+
this._closed = true;
|
|
236
|
+
const all = [];
|
|
237
|
+
for (const list of this._idle.values()) for (const e of list) all.push(e.conn);
|
|
238
|
+
this._idle.clear();
|
|
239
|
+
this._total = 0;
|
|
240
|
+
await Promise.all(all.map(discard));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_assertOpen() {
|
|
244
|
+
if (this._closed) {
|
|
245
|
+
throw new TunnelFetchError(codes.POOL_CLOSED, 'the connection pool has been closed');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function discard(conn) {
|
|
251
|
+
try {
|
|
252
|
+
await conn?.close?.();
|
|
253
|
+
} catch {
|
|
254
|
+
/* a connection being discarded is already suspect; its close failing changes nothing */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// No proxy: the socket is the tunnel.
|
|
2
|
+
//
|
|
3
|
+
// Worth stating plainly, because it shapes what this package is for: on the target runtime a
|
|
4
|
+
// direct connection cannot reach a large fraction of the public web at all — the platform refuses
|
|
5
|
+
// outbound TCP to its own address ranges, and a great many sites sit behind them. Direct mode is
|
|
6
|
+
// therefore useful for hosts that are demonstrably not behind the platform, and for exercising the
|
|
7
|
+
// TLS stack in tests; for everything else the proxy is not an optimisation, it is the only route.
|
|
8
|
+
|
|
9
|
+
import { ProxyError, codes } from '../errors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} DirectOptions
|
|
13
|
+
* @property {{ hostname: string, port: number }} target
|
|
14
|
+
* @property {import('./index.js').ConnectFn} connect injected socket factory
|
|
15
|
+
* @property {AbortSignal} [signal]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dial the target itself. Resolves with the raw socket duplex; a refused or failed dial throws
|
|
20
|
+
* ProxyError (PROXY_UNREACHABLE) quoting the runtime's own message, which is the best
|
|
21
|
+
* diagnostic a caller will get.
|
|
22
|
+
*
|
|
23
|
+
* @param {DirectOptions} args
|
|
24
|
+
* @returns {Promise<import('./index.js').Duplex>}
|
|
25
|
+
*/
|
|
26
|
+
export async function openDirect({ target, connect, signal }) {
|
|
27
|
+
signal?.throwIfAborted?.();
|
|
28
|
+
let socket;
|
|
29
|
+
try {
|
|
30
|
+
socket = connect({ hostname: target.hostname, port: target.port }, {
|
|
31
|
+
secureTransport: 'off',
|
|
32
|
+
allowHalfOpen: false,
|
|
33
|
+
});
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
throw new ProxyError(
|
|
36
|
+
codes.PROXY_UNREACHABLE,
|
|
37
|
+
`could not open a socket to ${target.hostname}:${target.port}: ${cause?.message ?? cause}`,
|
|
38
|
+
{ target: `${target.hostname}:${target.port}` },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (socket.opened) {
|
|
42
|
+
try {
|
|
43
|
+
await socket.opened;
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
// A failed dial still leaves a socket object behind; not closing it leaks one per attempt,
|
|
46
|
+
// which for a crawl retrying against an unreachable host adds up inside a single isolate.
|
|
47
|
+
try {
|
|
48
|
+
await socket.close?.();
|
|
49
|
+
} catch {
|
|
50
|
+
/* it never opened; close failing tells us nothing new */
|
|
51
|
+
}
|
|
52
|
+
// The runtime's refusal messages are the most useful diagnostic a caller will get here, so
|
|
53
|
+
// they are quoted rather than replaced.
|
|
54
|
+
throw new ProxyError(
|
|
55
|
+
codes.PROXY_UNREACHABLE,
|
|
56
|
+
`connection to ${target.hostname}:${target.port} failed: ${cause?.message ?? cause}`,
|
|
57
|
+
{ target: `${target.hostname}:${target.port}` },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return socket;
|
|
62
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// HTTP CONNECT tunnelling (RFC 9110 s9.3.6; wire syntax RFC 9112).
|
|
2
|
+
//
|
|
3
|
+
// Two details here are not theory — they were measured against a live proxy while building this:
|
|
4
|
+
//
|
|
5
|
+
// * The reply may be `HTTP/1.0 200 OK` even though we sent an HTTP/1.1 request. Matching on the
|
|
6
|
+
// version or the reason phrase would break against real deployments; only the status code is
|
|
7
|
+
// meaningful, and any 2xx opens the tunnel.
|
|
8
|
+
// * The reply header block can be as short as 19 bytes and the server may put tunnel payload in
|
|
9
|
+
// the SAME read as the terminating CRLFCRLF. Those bytes belong to the peer, not to us. Losing
|
|
10
|
+
// them silently truncates the first TLS record, which surfaces much later as an inexplicable
|
|
11
|
+
// handshake failure, so the reader that consumed the header block is handed on intact rather
|
|
12
|
+
// than being discarded.
|
|
13
|
+
|
|
14
|
+
import { ProxyError, LimitError, codes } from '../errors.js';
|
|
15
|
+
import { ByteReader, ByteWriter, latin1, utf8 } from '../util/bytes.js';
|
|
16
|
+
|
|
17
|
+
const CRLFCRLF = utf8('\r\n\r\n');
|
|
18
|
+
const MAX_REPLY_HEADER = 32 * 1024;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The tunnel a proxy module hands back: the byte duplex plus the underlying socket, kept so a
|
|
22
|
+
* caller that must tear down the transport can reach past the wrapping streams.
|
|
23
|
+
* @typedef {import('./index.js').Duplex & { socket: import('./index.js').Duplex }} ProxyTunnel
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} HttpConnectOptions
|
|
28
|
+
* @property {import('./index.js').ProxyConfig} proxy protocol 'http' or 'https'
|
|
29
|
+
* @property {{ hostname: string, port: number }} target
|
|
30
|
+
* @property {import('./index.js').ConnectFn} connect injected socket factory
|
|
31
|
+
* @property {AbortSignal} [signal]
|
|
32
|
+
* @property {{ maxProxyReplyBytes?: number }} [limits] CONNECT reply head cap, default 32768
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** RFC 7617: credentials are UTF-8 before base64, and btoa only accepts code units below 256. */
|
|
36
|
+
function basicCredentials(username, password) {
|
|
37
|
+
const raw = utf8(`${username}:${password ?? ''}`);
|
|
38
|
+
return btoa(latin1(raw));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** IPv6 literals must be bracketed in an authority, or the port cannot be told from the address. */
|
|
42
|
+
function authority(hostname, port) {
|
|
43
|
+
return hostname.includes(':') ? `[${hostname}]:${port}` : `${hostname}:${port}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Establish a CONNECT tunnel through an http/https proxy. Resolves with the tunnel duplex;
|
|
48
|
+
* every refusal (non-2xx, 407 with or without credentials, malformed reply) throws a
|
|
49
|
+
* ProxyError naming what the proxy answered.
|
|
50
|
+
*
|
|
51
|
+
* @param {HttpConnectOptions} args
|
|
52
|
+
* @returns {Promise<ProxyTunnel>}
|
|
53
|
+
*/
|
|
54
|
+
export async function openHttpConnect({ proxy, target, connect, signal, limits = {} }) {
|
|
55
|
+
signal?.throwIfAborted?.();
|
|
56
|
+
const overTls = proxy.protocol === 'https';
|
|
57
|
+
const where = `${proxy.hostname}:${proxy.port}`;
|
|
58
|
+
|
|
59
|
+
let socket;
|
|
60
|
+
try {
|
|
61
|
+
socket = connect(
|
|
62
|
+
{ hostname: proxy.hostname, port: proxy.port },
|
|
63
|
+
// For an https proxy the runtime does TLS to the proxy itself. That is the one hop where
|
|
64
|
+
// the platform's certificate check asks the right question: the identity it verifies is the
|
|
65
|
+
// hostname handed to connect(), which here IS the proxy. (It follows that an https proxy
|
|
66
|
+
// addressed by bare IP will fail that check, since public certificates carry no IP SAN.)
|
|
67
|
+
{ secureTransport: overTls ? 'on' : 'starttls', allowHalfOpen: false },
|
|
68
|
+
);
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
throw new ProxyError(
|
|
71
|
+
codes.PROXY_UNREACHABLE,
|
|
72
|
+
`could not open a socket to proxy ${where}: ${cause?.message ?? cause}`,
|
|
73
|
+
{ proxy: where },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let reader;
|
|
78
|
+
let writer;
|
|
79
|
+
try {
|
|
80
|
+
if (socket.opened) await socket.opened;
|
|
81
|
+
|
|
82
|
+
writer = new ByteWriter(socket.writable);
|
|
83
|
+
await writer.write(utf8(buildConnectRequest(proxy, target)));
|
|
84
|
+
writer.releaseLock();
|
|
85
|
+
|
|
86
|
+
reader = new ByteReader(socket.readable);
|
|
87
|
+
const max = limits.maxProxyReplyBytes ?? MAX_REPLY_HEADER;
|
|
88
|
+
let block;
|
|
89
|
+
try {
|
|
90
|
+
block = await reader.readUntil(CRLFCRLF, max, 'proxy CONNECT reply');
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
if (cause instanceof LimitError) throw cause;
|
|
93
|
+
throw new ProxyError(
|
|
94
|
+
codes.PROXY_PROTOCOL,
|
|
95
|
+
`proxy ${where} closed or misbehaved before completing its CONNECT reply: ${cause?.message ?? cause}`,
|
|
96
|
+
{ proxy: where },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const reply = parseReply(latin1(block), where);
|
|
100
|
+
|
|
101
|
+
if (reply.status < 200 || reply.status > 299) {
|
|
102
|
+
throw replyError(reply, proxy, target, where);
|
|
103
|
+
}
|
|
104
|
+
// Anything the proxy sent after the blank line is already tunnel payload. It stays buffered in
|
|
105
|
+
// `reader`, which becomes the tunnel's read side, so it cannot be dropped.
|
|
106
|
+
return tunnelFrom(socket, reader);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
try {
|
|
109
|
+
writer?.releaseLock();
|
|
110
|
+
await reader?.cancel(err);
|
|
111
|
+
await socket.close?.();
|
|
112
|
+
} catch {
|
|
113
|
+
/* the socket may already be unusable; the original error is what matters */
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildConnectRequest(proxy, target) {
|
|
120
|
+
const host = authority(target.hostname, target.port);
|
|
121
|
+
const lines = [`CONNECT ${host} HTTP/1.1`, `Host: ${host}`];
|
|
122
|
+
if (proxy.username) {
|
|
123
|
+
lines.push(`Proxy-Authorization: Basic ${basicCredentials(proxy.username, proxy.password)}`);
|
|
124
|
+
}
|
|
125
|
+
// Some proxies still key off the pre-standard hop header; sending it is harmless and avoids a
|
|
126
|
+
// class of proxy that closes the tunnel after one request without it.
|
|
127
|
+
lines.push('Proxy-Connection: keep-alive');
|
|
128
|
+
return `${lines.join('\r\n')}\r\n\r\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseReply(text, where) {
|
|
132
|
+
const [statusLine, ...headerLines] = text.split('\r\n');
|
|
133
|
+
const m = /^HTTP\/(\d)\.(\d) (\d{3})(?: (.*))?$/.exec(statusLine ?? '');
|
|
134
|
+
if (!m) {
|
|
135
|
+
throw new ProxyError(
|
|
136
|
+
codes.PROXY_PROTOCOL,
|
|
137
|
+
`proxy ${where} did not answer CONNECT with an HTTP status line (got ${JSON.stringify(
|
|
138
|
+
(statusLine ?? '').slice(0, 80),
|
|
139
|
+
)})`,
|
|
140
|
+
{ proxy: where, statusLine },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const headers = new Map();
|
|
144
|
+
for (const line of headerLines) {
|
|
145
|
+
if (line === '') break;
|
|
146
|
+
const i = line.indexOf(':');
|
|
147
|
+
if (i <= 0) continue; // a malformed header in an otherwise valid reply is not worth failing on
|
|
148
|
+
headers.set(line.slice(0, i).toLowerCase().trim(), line.slice(i + 1).trim());
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
httpVersion: `${m[1]}.${m[2]}`,
|
|
152
|
+
status: Number(m[3]),
|
|
153
|
+
reason: m[4] ?? '',
|
|
154
|
+
headers,
|
|
155
|
+
statusLine,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function replyError(reply, proxy, target, where) {
|
|
160
|
+
const to = authority(target.hostname, target.port);
|
|
161
|
+
if (reply.status === 407) {
|
|
162
|
+
const challenge = reply.headers.get('proxy-authenticate') ?? '(none sent)';
|
|
163
|
+
// Naming the scheme the proxy asked for is the whole value of this error: "auth failed" does
|
|
164
|
+
// not tell anyone whether they typed the password wrong or the proxy wants Digest/NTLM.
|
|
165
|
+
return proxy.username
|
|
166
|
+
? new ProxyError(
|
|
167
|
+
codes.PROXY_AUTH_FAILED,
|
|
168
|
+
`proxy ${where} rejected the supplied credentials for user "${proxy.username}" ` +
|
|
169
|
+
`(407 ${reply.reason}); it offers: ${challenge}`,
|
|
170
|
+
{ proxy: where, challenge, status: 407 },
|
|
171
|
+
)
|
|
172
|
+
: new ProxyError(
|
|
173
|
+
codes.PROXY_AUTH_REQUIRED,
|
|
174
|
+
`proxy ${where} requires authentication (407 ${reply.reason}); it offers: ${challenge}`,
|
|
175
|
+
{ proxy: where, challenge, status: 407 },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return new ProxyError(
|
|
179
|
+
codes.PROXY_CONNECT_REFUSED,
|
|
180
|
+
`proxy ${where} refused CONNECT to ${to}: ${reply.status} ${reply.reason}`.trimEnd(),
|
|
181
|
+
{ proxy: where, target: to, status: reply.status, reason: reply.reason },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wrap the still-buffered reader as the tunnel's readable so bytes that arrived alongside the
|
|
187
|
+
* CONNECT reply are delivered first, in order.
|
|
188
|
+
*/
|
|
189
|
+
function tunnelFrom(socket, reader) {
|
|
190
|
+
return {
|
|
191
|
+
readable: new ReadableStream({
|
|
192
|
+
async pull(controller) {
|
|
193
|
+
const chunk = await reader.readSome();
|
|
194
|
+
if (chunk === null) controller.close();
|
|
195
|
+
else controller.enqueue(chunk);
|
|
196
|
+
},
|
|
197
|
+
cancel(reason) {
|
|
198
|
+
return reader.cancel(reason);
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
writable: socket.writable,
|
|
202
|
+
opened: socket.opened,
|
|
203
|
+
close: () => socket.close?.(),
|
|
204
|
+
socket,
|
|
205
|
+
};
|
|
206
|
+
}
|