quico 0.3.0 → 0.7.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.
@@ -0,0 +1,770 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+ // ============================================================
16
+ // quico.connectUdp(proxy, target[, options][, callback]) — CONNECT-UDP's
17
+ // client (RFC 9298)
18
+ //
19
+ // A UDP "socket" to one target, through a proxy: what it returns behaves as
20
+ // a connected dgram.Socket — 'connect', send(msg[, cb]), 'message' (msg,
21
+ // rinfo), close(), 'close', 'error', remoteAddress() — so code written for
22
+ // dgram moves to a proxied path by changing the line that makes the socket.
23
+ //
24
+ // HTTP/3 first: each UDP payload a QUIC DATAGRAM frame (Quarter Stream ID,
25
+ // Context ID 0, the payload) — unreliable, as UDP. If the proxy has not
26
+ // answered QUIC within h3Timeout (UDP blocked on the way, a proxy without
27
+ // HTTP/3), TCP: HTTP/2 extended CONNECT or HTTP/1.1 Upgrade, as ALPN
28
+ // decides, with DATAGRAM capsules on the stream. The capsule format, and the
29
+ // template, are the server's (connect_udp.js).
30
+ //
31
+ // Over HTTP/3 the tunnels to one proxy share a connection (see below);
32
+ // shareConnection: false gives a tunnel one of its own.
33
+ // ============================================================
34
+
35
+ import { EventEmitter } from 'node:events';
36
+ import dns from 'node:dns';
37
+ import net from 'node:net';
38
+ import lemonTls from 'lemon-tls';
39
+ import { readVarInt, writeVarInt } from './utils.js';
40
+ import { tlsIdentity, globalAgent } from './agent.js';
41
+ import { H3Connection, H3_ERR_NAME } from './h3.js';
42
+ import { H2Connection } from './h2.js';
43
+ import { createQuicClientSocket } from './quic_socket.js';
44
+ import { createCapsuleParser, datagramCapsule, udpPayloadOf, MAX_UDP_PAYLOAD, STREAM_QUEUE_MAX } from './connect_udp.js';
45
+
46
+ var DEFAULT_TEMPLATE_PATH = '/.well-known/masque/udp/{target_host}/{target_port}/';
47
+ var QUEUE_MAX = 64; // payloads sent before the tunnel opened, held
48
+
49
+
50
+ // The request's path: the template with the target in it — the host
51
+ // percent-encoded (an IPv6 literal's colons too, §3), the port as a number.
52
+ // A query expansion ({?target_host,target_port}) is filled in as one.
53
+ function expandTemplate(template, host, port) {
54
+ var vals = { target_host: encodeURIComponent(host), target_port: String(port) };
55
+ return template.replace(/\{(\?)?([^}]*)\}/g, function (m, q, names) {
56
+ var list = names.split(',').map(function (s) { return s.trim(); });
57
+ if (q) return '?' + list.map(function (n) { return n + '=' + (vals[n] != null ? vals[n] : ''); }).join('&');
58
+ return list.map(function (n) { return vals[n] != null ? vals[n] : ''; }).join(',');
59
+ });
60
+ }
61
+
62
+ // The proxy: a URL — its origin, and, when it has {target_host}, the
63
+ // template too (as RFC 9298 clients are configured) — or { hostname, port,
64
+ // template }.
65
+ function parseProxy(proxy, options) {
66
+ var p = {};
67
+ if (typeof proxy === 'string' || proxy instanceof URL) {
68
+ var s = String(proxy);
69
+ var m = /^(https?):\/\/([^/?#]+)(.*)$/i.exec(s);
70
+ if (!m) throw new TypeError('connectUdp: the proxy must be an https:// URL');
71
+ var auth = m[2], rest = m[3] || '';
72
+ var hp = /^\[([^\]]+)\](?::(\d+))?$/.exec(auth) || /^([^:]+)(?::(\d+))?$/.exec(auth);
73
+ if (!hp) throw new TypeError('connectUdp: bad proxy authority ' + JSON.stringify(auth));
74
+ p.hostname = hp[1]; p.port = hp[2] ? Number(hp[2]) : 443;
75
+ p.template = rest.indexOf('{') >= 0 ? rest : (options.template || DEFAULT_TEMPLATE_PATH);
76
+ } else if (proxy && typeof proxy === 'object') {
77
+ p.hostname = proxy.hostname || proxy.host;
78
+ p.port = Number(proxy.port) || 443;
79
+ p.template = proxy.template || options.template || DEFAULT_TEMPLATE_PATH;
80
+ } else throw new TypeError('connectUdp: a proxy URL or { hostname, port } is needed');
81
+ if (!p.hostname) throw new TypeError('connectUdp: the proxy has no host');
82
+ if (p.template.indexOf('{target_host}') < 0 && !/\{\?[^}]*target_host/.test(p.template)) {
83
+ throw new TypeError('connectUdp: the template must contain {target_host} and {target_port}');
84
+ }
85
+ return p;
86
+ }
87
+
88
+ function connectUdp(proxy, target, options, callback) {
89
+ if (typeof options === 'function') { callback = options; options = null; }
90
+ options = options || {};
91
+ var px = parseProxy(proxy, options);
92
+ if (!target || !target.host) throw new TypeError('connectUdp: target { host, port } is needed');
93
+ var port = Number(target.port);
94
+ if (!(port >= 1 && port <= 65535)) throw new TypeError('connectUdp: target.port must be in 1..65535');
95
+ var protocol = options.protocol || 'auto';
96
+ if (['auto', 'h3', 'h2', 'http/1.1'].indexOf(protocol) < 0) throw new TypeError("connectUdp: protocol is 'auto', 'h3', 'h2' or 'http/1.1'");
97
+ var sock = new UdpProxySocket(px, { host: String(target.host), port: port }, options, protocol);
98
+ if (typeof callback === 'function') sock.once('connect', callback);
99
+ return sock;
100
+ }
101
+
102
+
103
+ // ============================================================
104
+ // The socket
105
+ // ============================================================
106
+
107
+ function UdpProxySocket(proxy, target, options, protocol) {
108
+ EventEmitter.call(this);
109
+ this._proxy = proxy;
110
+ this._target = target;
111
+ this._options = options;
112
+ // The key-exchange groups to offer (tls.connect's ecdhCurve): a name not
113
+ // implemented throws now, as tls.connect's does.
114
+ if (options && options.ecdhCurve != null) lemonTls.parseEcdhCurve(options.ecdhCurve);
115
+ if (options && options.sigalgs != null) lemonTls.parseSigalgs(options.sigalgs);
116
+ this._wanted = protocol;
117
+ this.protocol = null; // 'h3', 'h2', 'http/1.1' — once the tunnel is open
118
+ this.closeReason = null; // 'local', 'proxy' (it ended the tunnel), 'error'
119
+ this.statusCode = null; // the proxy's answer
120
+ this.responseHeaders = null;
121
+ this._state = 'connecting'; // → 'open' → 'closed'
122
+ this._t = null; // the transport: { send(u8) → 'sent'|'dropped'|'too-big', mode(), maxPayload(), close(), destroy(), address() }
123
+ this._queue = [];
124
+ this._stats = { packetsSent: 0, bytesSent: 0, packetsReceived: 0, bytesReceived: 0, dropped: 0, openedAt: null, closedAt: null };
125
+ this._path = expandTemplate(proxy.template, target.host, target.port);
126
+ // Encrypted Client Hello to the proxy: options.ech, or GREASE ECH unless
127
+ // echGrease is false. Rejected with new keys: once more, with them.
128
+ // The keys the proxy sent on rejecting these before (retry_configs) are
129
+ // kept by the agent and tried first, as quico.request's (client.js).
130
+ this._echOriginal = options.ech || null;
131
+ var echAgent = !this._echOriginal || options.agent === false ? null
132
+ : (options.agent && typeof options.agent._getEchRetry === 'function' ? options.agent : globalAgent);
133
+ this._echAgent = echAgent;
134
+ this._echKey = echAgent ? echAgent._sessionKey('ech', proxy.hostname, proxy.port, {
135
+ servername: options.servername, rejectUnauthorized: options.rejectUnauthorized, ca: options.ca, cert: options.cert, key: options.key,
136
+ ech: options.ech, ecdhCurve: options.ecdhCurve, sigalgs: options.sigalgs
137
+ }) : null;
138
+ this._echKept = this._echKey ? echAgent._getEchRetry(this._echKey) : null;
139
+ this._ech = this._echKept || this._echOriginal;
140
+ this._echRetried = false;
141
+ var self = this;
142
+ process.nextTick(function () { self._start(); });
143
+ }
144
+ Object.setPrototypeOf(UdpProxySocket.prototype, EventEmitter.prototype);
145
+
146
+ // ECH rejected with the proxy's current keys: true when another attempt,
147
+ // with them, has been made.
148
+ UdpProxySocket.prototype._echRetry = function (err, again) {
149
+ if (!err || err.code !== 'ERR_ECH_REJECTED' || this._state !== 'connecting') return false;
150
+ // The kept keys rejected too: forgotten, and the caller's tried again.
151
+ if (this._echKept) {
152
+ this._echAgent._evictEchRetry(this._echKey);
153
+ this._echKept = null;
154
+ this._ech = this._echOriginal;
155
+ again();
156
+ return true;
157
+ }
158
+ if (!err.retryConfigs || this._echRetried) return false;
159
+ this._echRetried = true;
160
+ this._ech = err.retryConfigs;
161
+ again();
162
+ return true;
163
+ };
164
+ UdpProxySocket.prototype._echOptions = function () {
165
+ return { ech: this._ech || undefined, echGrease: !this._ech && this._options.echGrease !== false };
166
+ };
167
+
168
+ UdpProxySocket.prototype._start = function () {
169
+ if (this._state !== 'connecting') return;
170
+ if (this._wanted === 'h2' || this._wanted === 'http/1.1') this._startTcp([this._wanted]);
171
+ else this._startH3();
172
+ };
173
+
174
+ // The request's own fields: the extended CONNECT's pseudo-headers, the
175
+ // application's (a Proxy-Authorization, say), capsule-protocol.
176
+ UdpProxySocket.prototype._authority = function () {
177
+ var h = this._proxy.hostname;
178
+ if (net.isIPv6(h)) h = '[' + h + ']';
179
+ return h + (this._proxy.port !== 443 ? ':' + this._proxy.port : '');
180
+ };
181
+ UdpProxySocket.prototype._extraHeaders = function () {
182
+ var out = {}, h = this._options.headers || {};
183
+ for (var n in h) out[String(n).toLowerCase()] = String(h[n]);
184
+ if (out['capsule-protocol'] == null) out['capsule-protocol'] = '?1';
185
+ return out;
186
+ };
187
+
188
+ // ---- the proxy answered ----
189
+
190
+ UdpProxySocket.prototype._answered = function (status, headers, protocol) {
191
+ if (this._state !== 'connecting') return;
192
+ this.statusCode = status;
193
+ this.responseHeaders = headers;
194
+ var ok = protocol === 'http/1.1' ? status === 101 : (status >= 200 && status < 300);
195
+ if (!ok) {
196
+ var err = new Error('CONNECT-UDP refused by the proxy: ' + status + (headers['proxy-status'] ? ' (' + headers['proxy-status'] + ')' : ''));
197
+ err.code = 'ERR_CONNECT_UDP_REFUSED';
198
+ err.statusCode = status;
199
+ err.proxyStatus = headers['proxy-status'] || null;
200
+ this._fail(err);
201
+ return;
202
+ }
203
+ this.protocol = protocol;
204
+ this._state = 'open';
205
+ if (this._echRetried && this._echKey) this._echAgent._cacheEchRetry(this._echKey, this._ech);
206
+ this._stats.openedAt = Date.now();
207
+ var q = this._queue; this._queue = null;
208
+ for (var i = 0; i < q.length; i++) this._sendNow(q[i].data, q[i].cb);
209
+ this.emit('connect');
210
+ };
211
+
212
+ UdpProxySocket.prototype._payload = function (u8) {
213
+ if (this._state === 'closed' || !u8) return;
214
+ this._stats.packetsReceived++;
215
+ this._stats.bytesReceived += u8.byteLength;
216
+ var buf = Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength);
217
+ this.emit('message', buf, { address: this._target.host, family: net.isIPv6(this._target.host) ? 'IPv6' : net.isIPv4(this._target.host) ? 'IPv4' : '',
218
+ port: this._target.port, size: buf.length });
219
+ };
220
+
221
+ UdpProxySocket.prototype._fail = function (err) {
222
+ if (this._state === 'closed') return;
223
+ this._finish('error', err);
224
+ };
225
+
226
+ // The tunnel's end: its stream ended by the proxy, reset, or the connection
227
+ // gone.
228
+ UdpProxySocket.prototype._ended = function (err) {
229
+ if (this._state === 'closed') return;
230
+ if (this._state === 'connecting' && !err) {
231
+ err = new Error('CONNECT-UDP: the proxy closed before answering'); err.code = 'ERR_CONNECT_UDP_CLOSED';
232
+ }
233
+ this._finish(err ? 'error' : 'proxy', err);
234
+ };
235
+
236
+ UdpProxySocket.prototype._finish = function (reason, err) {
237
+ this._state = 'closed';
238
+ this.closeReason = reason;
239
+ this._stats.closedAt = Date.now();
240
+ var q = this._queue; this._queue = null;
241
+ for (var i = 0; q && i < q.length; i++) if (q[i].cb) process.nextTick(q[i].cb, notOpen());
242
+ var t = this._t;
243
+ if (t) { if (reason === 'local') t.close(); else t.destroy(); }
244
+ if (err) this.emit('error', err);
245
+ var self = this;
246
+ process.nextTick(function () { self.emit('close'); }); // as a socket's: after 'error', a tick later
247
+ };
248
+
249
+ function notOpen() {
250
+ var e = new Error('Not running'); e.code = 'ERR_SOCKET_DGRAM_NOT_RUNNING'; return e;
251
+ }
252
+
253
+ // ---- dgram.Socket's surface ----
254
+
255
+ // send(msg[, offset, length][, callback]) — a connected socket's forms.
256
+ // msg: a Buffer, a TypedArray, a DataView, a string, or an array of them
257
+ // (sent as one payload). Before the tunnel opens, held — as many as
258
+ // QUEUE_MAX — and sent when it does.
259
+ UdpProxySocket.prototype.send = function (msg, offset, length, port, address, callback) {
260
+ var args = Array.prototype.slice.call(arguments);
261
+ var cb = typeof args[args.length - 1] === 'function' ? args.pop() : null;
262
+ // A port (and an address) — told from (offset, length) as dgram tells
263
+ // them: by the address being a string. Not for a connected socket.
264
+ if (args.length === 2 || args.length > 3 || (args.length === 3 && typeof args[2] === 'string')) {
265
+ var ce = new Error('Already connected'); ce.code = 'ERR_SOCKET_DGRAM_IS_CONNECTED'; throw ce;
266
+ }
267
+ var data = toBytes(msg);
268
+ if (args.length === 3) data = data.subarray(Number(args[1]) || 0, (Number(args[1]) || 0) + Number(args[2]));
269
+ if (this._state === 'closed') { if (cb) process.nextTick(cb, notOpen()); return; }
270
+ if (this._state === 'connecting') {
271
+ if (this._queue.length >= QUEUE_MAX) { this._stats.dropped++; if (cb) process.nextTick(cb, null, data.byteLength); return; }
272
+ this._queue.push({ data: Uint8Array.prototype.slice.call(data), cb: cb });
273
+ return;
274
+ }
275
+ this._sendNow(data, cb);
276
+ };
277
+
278
+ UdpProxySocket.prototype._sendNow = function (data, cb) {
279
+ if (data.byteLength > MAX_UDP_PAYLOAD) { if (cb) process.nextTick(cb, emsgsize()); return; }
280
+ var r = this._t.send(data);
281
+ if (r === 'too-big') { this._stats.dropped++; if (cb) process.nextTick(cb, emsgsize()); return; }
282
+ if (r === 'dropped') this._stats.dropped++; // as the kernel drops on a full queue: no error
283
+ else { this._stats.packetsSent++; this._stats.bytesSent += data.byteLength; }
284
+ if (cb) process.nextTick(cb, null, data.byteLength);
285
+ };
286
+
287
+ function emsgsize() {
288
+ var e = new Error('send EMSGSIZE: larger than the tunnel carries (see maxPayloadSize)'); e.code = 'EMSGSIZE'; return e;
289
+ }
290
+
291
+ function toBytes(msg) {
292
+ if (typeof msg === 'string') return Buffer.from(msg);
293
+ if (Array.isArray(msg)) return Buffer.concat(msg.map(toBytes));
294
+ if (msg instanceof Uint8Array) return msg;
295
+ if (ArrayBuffer.isView(msg)) return new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
296
+ if (msg instanceof ArrayBuffer) return new Uint8Array(msg);
297
+ throw new TypeError('The "msg" argument must be a Buffer, TypedArray, DataView, string or array');
298
+ }
299
+
300
+ // Ends the tunnel: the request stream's end, then the connection's.
301
+ UdpProxySocket.prototype.close = function (callback) {
302
+ if (typeof callback === 'function') this.once('close', callback);
303
+ if (this._state === 'closed') return this;
304
+ this._finish('local', null);
305
+ return this;
306
+ };
307
+
308
+ // The target, as a connected dgram socket names its peer.
309
+ UdpProxySocket.prototype.remoteAddress = function () {
310
+ if (this._state !== 'open') { var e = new Error('Not connected'); e.code = 'ERR_SOCKET_DGRAM_NOT_CONNECTED'; throw e; }
311
+ var h = this._target.host;
312
+ return { address: h, family: net.isIPv6(h) ? 'IPv6' : net.isIPv4(h) ? 'IPv4' : '', port: this._target.port };
313
+ };
314
+ // The local end of the connection to the proxy.
315
+ UdpProxySocket.prototype.address = function () {
316
+ return this._t && this._t.address ? this._t.address() : {};
317
+ };
318
+ UdpProxySocket.prototype.ref = function () { return this; };
319
+ UdpProxySocket.prototype.unref = function () { return this; };
320
+
321
+ // The largest payload send() takes now: over HTTP/3 datagrams, what a QUIC
322
+ // DATAGRAM frame can carry on this path; over capsules, a UDP payload's.
323
+ Object.defineProperty(UdpProxySocket.prototype, 'maxPayloadSize', { get: function () {
324
+ return this._t ? this._t.maxPayload() : 0;
325
+ } });
326
+ Object.defineProperty(UdpProxySocket.prototype, 'stats', { get: function () {
327
+ var s = Object.assign({ protocol: this.protocol, transport: this._t ? this._t.mode() : null,
328
+ target: { host: this._target.host, port: this._target.port } }, this._stats);
329
+ s.durationMs = s.openedAt ? (s.closedAt || Date.now()) - s.openedAt : 0;
330
+ return s;
331
+ } });
332
+
333
+
334
+ // ============================================================
335
+ // HTTP/3: one connection per proxy, shared by its tunnels
336
+ // ============================================================
337
+ //
338
+ // RFC 9298's tunnels are requests: as many as the proxy allows run over one
339
+ // HTTP/3 connection, each its own request stream, their datagrams told apart
340
+ // by Quarter Stream ID (RFC 9297 §2.1). A socket per connection — as this
341
+ // was — cost each tunnel a handshake (a round trip, two before 0.5 RTT of
342
+ // data on a lossy path), a congestion controller starting from nothing, and
343
+ // a UDP port; a VPN or a game client opening a tunnel per flow paid it per
344
+ // flow. Now a tunnel joins the connection its proxy already has — opened
345
+ // with the same TLS settings (agent.js tlsIdentity) — or waits for the one
346
+ // being made; the connection ends with its last tunnel.
347
+ // shareConnection: false keeps a tunnel on a connection of its own.
348
+
349
+ var h3Connections = new Map(); // pool key → ProxyH3Connection
350
+ var unsharedSeq = 0;
351
+
352
+ function h3PoolKey(proxy, o, ech) {
353
+ if (o.shareConnection === false) return 'unshared#' + (++unsharedSeq);
354
+ return [proxy.hostname, proxy.port, (o.quicVersions || []).join(','), tlsIdentity({
355
+ servername: o.servername, rejectUnauthorized: o.rejectUnauthorized, ca: o.ca, cert: o.cert, key: o.key,
356
+ ech: ech.ech, ecdhCurve: o.ecdhCurve, sigalgs: o.sigalgs
357
+ }), ech.echGrease ? 'grease' : ''].join('|');
358
+ }
359
+
360
+ // TLS session resumption to the proxy, as quico.request has it: a session
361
+ // from the agent's cache (options.agent, or the global one; agent: false —
362
+ // none), each used once, and each the proxy sends kept there. An explicit
363
+ // options.session wins. Every tunnel began with a full handshake.
364
+ // transport: 'quic', or 'tcp:<alpn>'. A session the handshake then failed
365
+ // with is dropped.
366
+ function proxySessions(o, transport, proxy, ech) {
367
+ var agent = o.agent === false ? null : (o.agent && typeof o.agent._sessionKey === 'function' ? o.agent : globalAgent);
368
+ var key = agent ? agent._sessionKey(transport, proxy.hostname, proxy.port, {
369
+ servername: o.servername, rejectUnauthorized: o.rejectUnauthorized, ca: o.ca, cert: o.cert, key: o.key,
370
+ ech: ech.ech, ecdhCurve: o.ecdhCurve, sigalgs: o.sigalgs
371
+ }) : null;
372
+ var taken = false, offered;
373
+ return {
374
+ take: function () {
375
+ if (o.session) return o.session;
376
+ if (!taken) { taken = true; offered = key ? agent._takeSession(key) : undefined; }
377
+ return offered;
378
+ },
379
+ keep: function (s) { if (key) agent._cacheSession(key, s); },
380
+ failed: function () { if (key && taken && offered && !o.session) agent._evictSession(key); }
381
+ };
382
+ }
383
+
384
+ function ProxyH3Connection(key, proxy, o, ech) {
385
+ this.key = key;
386
+ this.state = 'connecting'; // → 'open' → 'closing' | 'closed'
387
+ this.quic = null; this.h3 = null; this.udp = null;
388
+ this.waiters = []; // (err, conn), until the handshake ends
389
+ this.tunnels = new Map(); // request stream ID → the tunnel's handlers
390
+ this.nextSid = 0; // client bidirectional: 0, 4, 8, …
391
+ this.sessions = proxySessions(o, 'quic', proxy, ech);
392
+ var self = this;
393
+ resolve(proxy.hostname, function (err, ip) {
394
+ if (err) { self._failed(err); return; }
395
+ if (self.state !== 'connecting') return;
396
+ self.udp = createQuicClientSocket({
397
+ remoteIp: ip, remotePort: proxy.port, hostname: o.servername || proxy.hostname,
398
+ rejectUnauthorized: o.rejectUnauthorized !== false, ca: o.ca || null,
399
+ key: o.key || null, cert: o.cert || null, passphrase: o.passphrase,
400
+ quicVersions: o.quicVersions,
401
+ startupCap: o.startupCap, pmtuDiscovery: o.pmtuDiscovery, maxUdpPayloadSize: o.maxUdpPayloadSize,
402
+ initialRtt: o.initialRtt, reorderWindow: o.reorderWindow,
403
+ ech: ech.ech, echGrease: ech.echGrease,
404
+ ecdhCurve: o.ecdhCurve,
405
+ sigalgs: o.sigalgs,
406
+ session: self.sessions.take() || null,
407
+ // Each ticket the proxy sends: the agent's, and each tunnel's 'session'.
408
+ onSocket: function (q) {
409
+ self.quic = q;
410
+ q.on('session', function (s) {
411
+ self.sessions.keep(s);
412
+ self.tunnels.forEach(function (t) { if (t.session) t.session(s); });
413
+ });
414
+ },
415
+ onError: function (e) { if (self.state === 'connecting') { self.sessions.failed(); self._failed(e); } },
416
+ onConnect: function (q, s) { self._connected(q, s); },
417
+ // The connection went: each tunnel on it with it.
418
+ onClose: function () { self._gone(); }
419
+ });
420
+ });
421
+ }
422
+
423
+ ProxyH3Connection.prototype._failed = function (err) {
424
+ if (this.state !== 'connecting') return;
425
+ this.state = 'closed';
426
+ if (h3Connections.get(this.key) === this) h3Connections.delete(this.key);
427
+ var w = this.waiters; this.waiters = [];
428
+ for (var i = 0; i < w.length; i++) w[i](err, null);
429
+ };
430
+
431
+ ProxyH3Connection.prototype._connected = function (q, s) {
432
+ var self = this;
433
+ this.quic = q; this.udp = s;
434
+ if (this.state !== 'connecting') { try { q.close(0x100, 'done', true); } catch (e) {} return; }
435
+ // Every tunnel that waited gave up on HTTP/3 meanwhile (h3Timeout).
436
+ if (this.waiters.length === 0) { this.state = 'closed'; h3Connections.delete(this.key); try { q.close(0x100, 'done', true); } catch (e) {} return; }
437
+ this.state = 'open';
438
+ var h3c = this.h3 = new H3Connection({ quicConnection: q, isServer: false, enableDatagrams: true });
439
+ h3c.on('error', function () {});
440
+ // The connection's events, each to the tunnel whose stream it names.
441
+ function to(name) { return function (id) { var t = self.tunnels.get(id); if (t) t[name].apply(null, Array.prototype.slice.call(arguments, 1)); }; }
442
+ h3c.on('http_headers', to('headers'));
443
+ h3c.on('http_body', to('body'));
444
+ h3c.on('http_end', to('end'));
445
+ h3c.on('stream_reset', to('reset'));
446
+ h3c.on('stream_error', to('error'));
447
+ q.on('datagram', function (raw) {
448
+ if (!raw || raw.byteLength === 0) return;
449
+ var qid = readVarInt(raw, 0);
450
+ if (!qid) return;
451
+ var t = self.tunnels.get(qid.value * 4);
452
+ if (t) t.datagram(raw.subarray(qid.byteLength));
453
+ });
454
+ h3c.sendControlStreams();
455
+ var w = this.waiters; this.waiters = [];
456
+ for (var i = 0; i < w.length; i++) w[i](null, this);
457
+ };
458
+
459
+ ProxyH3Connection.prototype._gone = function () {
460
+ var was = this.state;
461
+ this.state = 'closed';
462
+ if (h3Connections.get(this.key) === this) h3Connections.delete(this.key);
463
+ if (was === 'connecting') { this._failed(connLost()); return; }
464
+ var ts = Array.from(this.tunnels.values()); this.tunnels.clear();
465
+ for (var i = 0; i < ts.length; i++) ts[i].lost();
466
+ };
467
+
468
+ // A tunnel's stream, and its handlers.
469
+ ProxyH3Connection.prototype.open = function (handlers) {
470
+ var sid = this.nextSid; this.nextSid += 4;
471
+ this.tunnels.set(sid, handlers);
472
+ return sid;
473
+ };
474
+
475
+ // A tunnel done with its stream. The last one closes the connection — once
476
+ // what was sent has been acknowledged (or a second has passed); a tunnel
477
+ // made after that opens another.
478
+ ProxyH3Connection.prototype.release = function (sid) {
479
+ if (!this.tunnels.delete(sid) || this.tunnels.size > 0 || this.state !== 'open') return;
480
+ this.state = 'closing';
481
+ if (h3Connections.get(this.key) === this) h3Connections.delete(this.key);
482
+ var q = this.quic;
483
+ var t = setTimeout(done, 1000);
484
+ q.whenAllSent(function () { clearTimeout(t); done(); });
485
+ function done() { try { q.close(0x100, 'done', true); } catch (e) {} }
486
+ };
487
+
488
+ // The connection for this proxy: the open one, the one being made, or a new
489
+ // one. cb(err, conn) — once; cancel() takes the tunnel out of the wait.
490
+ function acquireH3(proxy, o, ech, cb) {
491
+ var key = h3PoolKey(proxy, o, ech);
492
+ var c = h3Connections.get(key);
493
+ if (!c || c.state === 'closing' || c.state === 'closed') { c = new ProxyH3Connection(key, proxy, o, ech); h3Connections.set(key, c); }
494
+ if (c.state === 'open') { var conn = c; process.nextTick(function () { cb(null, conn); }); return function () {}; }
495
+ c.waiters.push(cb);
496
+ return function cancel() {
497
+ var i = c.waiters.indexOf(cb);
498
+ if (i >= 0) c.waiters.splice(i, 1);
499
+ };
500
+ }
501
+
502
+ UdpProxySocket.prototype._startH3 = function () {
503
+ var self = this, o = this._options;
504
+ var fallback = this._wanted === 'auto' && o.fallback !== false;
505
+ var given = false, timer = null, cancel = null;
506
+
507
+ // HTTP/3 given up on: this tunnel out of the wait, and TCP tried — or,
508
+ // with no fallback, the socket fails.
509
+ function giveUp(err) {
510
+ if (given || self._state !== 'connecting') return;
511
+ given = true;
512
+ if (timer) { clearTimeout(timer); timer = null; }
513
+ if (cancel) cancel();
514
+ if (self._echRetry(err, function () { self._startH3(); })) return;
515
+ if (fallback) self._startTcp(['h2', 'http/1.1']);
516
+ else self._fail(err);
517
+ }
518
+
519
+ var h3Timeout = o.h3Timeout != null ? o.h3Timeout : 3000;
520
+ if (fallback && h3Timeout > 0) {
521
+ timer = setTimeout(function () {
522
+ timer = null;
523
+ var e = new Error('HTTP/3: no answer within h3Timeout'); e.code = 'ERR_HTTP3_TIMEOUT';
524
+ giveUp(e);
525
+ }, h3Timeout);
526
+ }
527
+
528
+ cancel = acquireH3(this._proxy, o, this._echOptions(), function (err, conn) {
529
+ if (given) return;
530
+ if (err) { giveUp(err); return; }
531
+ given = true;
532
+ if (timer) { clearTimeout(timer); timer = null; }
533
+ if (self._state !== 'connecting') return; // closed meanwhile
534
+ self._overH3(conn);
535
+ });
536
+ };
537
+
538
+ UdpProxySocket.prototype._overH3 = function (conn) {
539
+ var self = this, q = conn.quic, h3c = conn.h3;
540
+ var read = createCapsuleParser(function (u8) { self._payload(udpPayloadOf(u8)); }, function () { self._stats.dropped++; });
541
+ function datagramsOn() { return h3c.context.remote_datagram_support === true && q.maxDatagramSize() > 0; }
542
+ var busy = false, done = false;
543
+ var sid = conn.open({
544
+ headers: function (hdrs) {
545
+ var status = parseInt(hdrs[':status'], 10) || 0;
546
+ if (status >= 100 && status < 200) return; // 1xx: the answer follows
547
+ if (self._state !== 'connecting') return; // trailers
548
+ var fields = {};
549
+ for (var n in hdrs) if (n.charAt(0) !== ':') fields[n] = hdrs[n];
550
+ self._answered(status, fields, 'h3');
551
+ },
552
+ body: function (data) { if (self._state === 'open') read(data); },
553
+ end: function () { release(); self._ended(null); },
554
+ reset: function (code) {
555
+ release();
556
+ var e = new Error('CONNECT-UDP stream reset by the proxy: ' + (H3_ERR_NAME[code] || code)); e.code = 'ERR_CONNECT_UDP_RESET'; e.errorCode = code;
557
+ self._ended(e);
558
+ },
559
+ error: function (code, reason) {
560
+ release();
561
+ var e = new Error('CONNECT-UDP stream error: ' + reason); e.code = 'ERR_HTTP3_STREAM_ERROR'; e.errorCode = code;
562
+ self._ended(e);
563
+ },
564
+ datagram: function (u8) { self._payload(udpPayloadOf(u8)); },
565
+ // The connection went: before the answer, the tunnel was never made;
566
+ // after it, it was lost (a tunnel the proxy ended had its FIN first).
567
+ session: function (s) { self.emit('session', s); },
568
+ lost: function () { done = true; self._ended(self._state === 'open' ? connLost() : null); }
569
+ });
570
+ var qsid = writeVarInt(sid / 4);
571
+ function release() { if (done) return; done = true; conn.release(sid); }
572
+
573
+ this._t = {
574
+ mode: function () { return datagramsOn() ? 'datagram' : 'capsule'; },
575
+ maxPayload: function () {
576
+ if (!datagramsOn()) return MAX_UDP_PAYLOAD;
577
+ return Math.max(0, q.maxDatagramSize() - qsid.length - 1); // Quarter Stream ID, Context ID 0
578
+ },
579
+ send: function (u8) {
580
+ if (datagramsOn()) {
581
+ var dg = new Uint8Array(qsid.length + 1 + u8.byteLength);
582
+ dg.set(qsid, 0); dg[qsid.length] = 0; // Quarter Stream ID, Context ID 0
583
+ dg.set(u8, qsid.length + 1);
584
+ if (dg.byteLength > q.maxDatagramSize()) return 'too-big';
585
+ return q.sendDatagram(dg, null, null) ? 'sent' : 'dropped';
586
+ }
587
+ if (busy) return 'dropped';
588
+ h3c.sendBody(sid, datagramCapsule(u8), false);
589
+ busy = true;
590
+ q.whenStreamSent(sid, STREAM_QUEUE_MAX, function () { busy = false; });
591
+ return 'sent';
592
+ },
593
+ // Ours ends: FIN on the stream; the connection's end is the last
594
+ // tunnel's (release).
595
+ close: function () {
596
+ if (done) return;
597
+ try { h3c.sendBody(sid, null, true); } catch (e) {}
598
+ release();
599
+ },
600
+ // Given up on: the stream reset (H3_REQUEST_CANCELLED), the others on
601
+ // the connection untouched.
602
+ destroy: function () {
603
+ if (done) return;
604
+ try { h3c.abortStream(sid, 0x10c); } catch (e) {}
605
+ release();
606
+ },
607
+ address: function () { try { return conn.udp.address(); } catch (e) { return {}; } }
608
+ };
609
+
610
+ var h = { ':method': 'CONNECT', ':protocol': 'connect-udp', ':scheme': 'https', ':authority': this._authority(), ':path': this._path };
611
+ var extra = this._extraHeaders();
612
+ for (var k in extra) h[k] = extra[k];
613
+ h3c.sendHeaders(sid, h, false);
614
+ };
615
+
616
+
617
+ // ============================================================
618
+ // TCP: HTTP/2 (extended CONNECT, RFC 8441) or HTTP/1.1 (Upgrade)
619
+ // ============================================================
620
+
621
+ UdpProxySocket.prototype._startTcp = function (alpn) {
622
+ var self = this, o = this._options;
623
+ var socket;
624
+ var sessions = proxySessions(o, 'tcp:' + alpn[0], this._proxy, this._echOptions());
625
+ try {
626
+ socket = lemonTls.connect({
627
+ session: sessions.take() || undefined,
628
+ host: this._proxy.hostname, port: this._proxy.port,
629
+ servername: o.servername || (net.isIP(this._proxy.hostname) ? undefined : this._proxy.hostname),
630
+ ALPNProtocols: alpn,
631
+ rejectUnauthorized: o.rejectUnauthorized !== false, ca: o.ca,
632
+ key: o.key, cert: o.cert, passphrase: o.passphrase,
633
+ ech: this._echOptions().ech, echGrease: this._echOptions().echGrease,
634
+ ecdhCurve: o.ecdhCurve,
635
+ sigalgs: o.sigalgs
636
+ }, function () {
637
+ if (self._state !== 'connecting') { try { socket.destroy(); } catch (e) {} return; } // closed meanwhile
638
+ var p = socket.alpnProtocol;
639
+ if (p === 'h2' && alpn.indexOf('h2') >= 0) self._overH2(socket);
640
+ else if ((p === 'http/1.1' || !p) && alpn.indexOf('http/1.1') >= 0) self._overH1(socket);
641
+ else {
642
+ try { socket.destroy(); } catch (e) {}
643
+ var e = new Error('the proxy negotiated ' + JSON.stringify(p) + ', not ' + alpn.join(' or ')); e.code = 'ERR_CONNECT_UDP_ALPN';
644
+ self._fail(e);
645
+ }
646
+ });
647
+ } catch (err) { this._fail(err); return; }
648
+ socket.on('session', function (s) { sessions.keep(s); self.emit('session', s); });
649
+ socket.on('error', function (err) {
650
+ if (self._state === 'connecting') sessions.failed();
651
+ if (self._echRetry(err, function () { self._startTcp(alpn); })) return;
652
+ if (self._state === 'connecting') self._fail(err);
653
+ else self._ended(err);
654
+ });
655
+ };
656
+
657
+ // Capsules on a byte stream: what both TCP versions write, and how much may
658
+ // wait — past STREAM_QUEUE_MAX, a payload is dropped, as UDP drops.
659
+ function streamTransport(writable, write, close, destroy, address) {
660
+ return {
661
+ mode: function () { return 'capsule'; },
662
+ maxPayload: function () { return MAX_UDP_PAYLOAD; },
663
+ send: function (u8) {
664
+ if (writable.destroyed || writable.writableLength > STREAM_QUEUE_MAX) return 'dropped';
665
+ write(datagramCapsule(u8));
666
+ return 'sent';
667
+ },
668
+ close: close, destroy: destroy, address: address
669
+ };
670
+ }
671
+
672
+ UdpProxySocket.prototype._overH2 = function (socket) {
673
+ var self = this;
674
+ var h2 = new H2Connection({ isServer: false });
675
+ var id = 1, opened = false, ended = false;
676
+ var read = createCapsuleParser(function (u8) { self._payload(udpPayloadOf(u8)); }, function () { self._stats.dropped++; });
677
+ h2.on('send', function (b) { if (!socket.destroyed) socket.write(b); });
678
+ h2.on('error', function (err) { self._ended(err); try { socket.destroy(); } catch (e) {} });
679
+ socket.on('data', function (d) { try { h2.execute(d); } catch (e) { self._ended(e); try { socket.destroy(); } catch (e2) {} } });
680
+ socket.on('close', function () { self._ended(self._state === 'open' && !ended ? connLost() : null); });
681
+
682
+ self._t = streamTransport(socket, function (bytes) { h2.sendData(id, Buffer.from(bytes), false); },
683
+ function () { try { h2.sendData(id, null, true); } catch (e) {} setTimeout(function () { try { socket.end(); } catch (e) {} }, 50); },
684
+ function () { try { socket.destroy(); } catch (e) {} },
685
+ function () { try { return socket.address(); } catch (e) { return {}; } });
686
+
687
+ // RFC 8441 §3: :protocol only once the server has said it takes it
688
+ // (SETTINGS_ENABLE_CONNECT_PROTOCOL).
689
+ h2.on('settings', function (o, list) {
690
+ if (opened) return;
691
+ opened = true;
692
+ var ok = (list || []).some(function (s) { return s.id === 8 && s.value === 1; });
693
+ if (!ok) {
694
+ var e = new Error('the proxy does not take extended CONNECT over HTTP/2'); e.code = 'ERR_CONNECT_UDP_NOT_SUPPORTED';
695
+ self._fail(e);
696
+ return;
697
+ }
698
+ var list2 = [[':method', 'CONNECT'], [':protocol', 'connect-udp'], [':scheme', 'https'], [':authority', self._authority()], [':path', self._path]];
699
+ var extra = self._extraHeaders();
700
+ for (var k in extra) list2.push([k, extra[k]]);
701
+ h2.sendHeaders(id, list2, false);
702
+ });
703
+ h2.on('headers', function (sid, hd) {
704
+ if (sid !== id || self._state !== 'connecting') return;
705
+ var fields = {}, status = 0;
706
+ var pairs = Array.isArray(hd) ? hd : Object.keys(hd).map(function (k) { return [k, hd[k]]; });
707
+ for (var i = 0; i < pairs.length; i++) {
708
+ if (pairs[i][0] === ':status') status = parseInt(pairs[i][1], 10) || 0;
709
+ else if (pairs[i][0].charAt(0) !== ':') fields[pairs[i][0]] = pairs[i][1];
710
+ }
711
+ if (status >= 100 && status < 200) return;
712
+ self._answered(status, fields, 'h2');
713
+ });
714
+ h2.on('data', function (sid, d) { if (sid === id && self._state === 'open') read(d); });
715
+ h2.on('end', function (sid) { if (sid === id) { ended = true; self._ended(null); } });
716
+ h2.on('rst', function (sid, code) {
717
+ if (sid !== id) return;
718
+ var e = new Error('CONNECT-UDP stream reset by the proxy (HTTP/2 error ' + code + ')'); e.code = 'ERR_CONNECT_UDP_RESET'; e.errorCode = code;
719
+ self._ended(e);
720
+ });
721
+ h2.sendPreface();
722
+ };
723
+
724
+ UdpProxySocket.prototype._overH1 = function (socket) {
725
+ var self = this;
726
+ var head = Buffer.alloc(0), inHead = true;
727
+ var read = createCapsuleParser(function (u8) { self._payload(udpPayloadOf(u8)); }, function () { self._stats.dropped++; });
728
+ self._t = streamTransport(socket, function (bytes) { socket.write(bytes); },
729
+ function () { try { socket.end(); } catch (e) {} },
730
+ function () { try { socket.destroy(); } catch (e) {} },
731
+ function () { try { return socket.address(); } catch (e) { return {}; } });
732
+ socket.on('data', function (d) {
733
+ if (!inHead) { if (self._state === 'open') read(d); return; }
734
+ head = Buffer.concat([head, d]);
735
+ var end = head.indexOf('\r\n\r\n');
736
+ if (end < 0) { if (head.length > 65536) self._fail(new Error('CONNECT-UDP: response head too large')); return; }
737
+ inHead = false;
738
+ var lines = head.subarray(0, end).toString('latin1').split('\r\n');
739
+ var m = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0]);
740
+ var fields = {};
741
+ for (var i = 1; i < lines.length; i++) {
742
+ var c = lines[i].indexOf(':');
743
+ if (c > 0) fields[lines[i].slice(0, c).trim().toLowerCase()] = lines[i].slice(c + 1).trim();
744
+ }
745
+ var status = m ? Number(m[1]) : 0;
746
+ if (status === 101 && String(fields.upgrade || '').toLowerCase() !== 'connect-udp') status = 0;
747
+ self._answered(status, fields, 'http/1.1');
748
+ if (self._state === 'open' && head.length > end + 4) read(head.subarray(end + 4));
749
+ head = null;
750
+ });
751
+ socket.on('end', function () { self._ended(null); });
752
+ socket.on('close', function () { self._ended(null); });
753
+ var req = 'GET ' + self._path + ' HTTP/1.1\r\nHost: ' + self._authority() + '\r\nConnection: Upgrade\r\nUpgrade: connect-udp\r\n';
754
+ var extra = self._extraHeaders();
755
+ for (var k in extra) req += k + ': ' + extra[k].replace(/[\r\n]/g, '') + '\r\n';
756
+ socket.write(req + '\r\n');
757
+ };
758
+
759
+ function connLost() {
760
+ var e = new Error('CONNECT-UDP: the connection to the proxy was lost'); e.code = 'ERR_CONNECT_UDP_CLOSED'; return e;
761
+ }
762
+
763
+ function resolve(host, cb) {
764
+ if (net.isIP(host)) { cb(null, host); return; }
765
+ if (host === 'localhost') { cb(null, '127.0.0.1'); return; }
766
+ dns.lookup(host, function (err, address) { cb(err, address); });
767
+ }
768
+
769
+
770
+ export { connectUdp, UdpProxySocket, expandTemplate };