quico 0.4.0 → 0.8.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/README.md +1987 -160
- package/index.d.ts +1034 -77
- package/index.js +59 -36
- package/package.json +8 -5
- package/src/agent.js +306 -36
- package/src/client.js +447 -120
- package/src/client_profile.js +266 -0
- package/src/connect_udp.js +578 -0
- package/src/connect_udp_client.js +779 -0
- package/src/crypto.js +240 -140
- package/src/h1.js +953 -0
- package/src/h1_client.js +731 -0
- package/src/h1_server.js +666 -0
- package/src/h2.js +1325 -0
- package/src/h2_client.js +675 -0
- package/src/h2_fingerprint.js +403 -0
- package/src/h2_frames.js +582 -0
- package/src/h2_server.js +911 -0
- package/src/h3.js +1986 -520
- package/src/h3_client.js +484 -46
- package/src/h3_server.js +1448 -98
- package/src/hpack.js +455 -0
- package/src/hpack_primitives.js +559 -0
- package/src/http_common.js +908 -0
- package/src/ja4h.js +256 -0
- package/src/qpack.js +555 -0
- package/src/quic_connection.js +6702 -665
- package/src/quic_server.js +858 -82
- package/src/quic_socket.js +134 -9
- package/src/quic_version.js +143 -0
- package/src/server.js +1097 -125
- package/src/server_stats.js +93 -0
- package/src/streams.js +542 -43
- package/src/tcp_listener.js +568 -0
- package/src/tls_bridge.js +478 -73
- package/src/tls_options.js +108 -0
- package/src/transport.js +455 -46
- package/src/utils.js +94 -12
- package/src/webtransport.js +784 -417
- package/src/ws.js +744 -0
- package/src/ws_client.js +621 -0
- package/src/ws_server.js +815 -0
- package/src/ws_stream.js +250 -0
- package/src/wt_stream.js +1075 -0
|
@@ -0,0 +1,578 @@
|
|
|
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
|
+
// Proxying UDP in HTTP — CONNECT-UDP (RFC 9298), the server's side
|
|
17
|
+
//
|
|
18
|
+
// A client asks the proxy for a UDP "socket" to one target — a host and a
|
|
19
|
+
// port, named in the request's path by a URI template — and from then on
|
|
20
|
+
// sends it UDP payloads, and receives the target's, as HTTP Datagrams
|
|
21
|
+
// (RFC 9297): over HTTP/3 a QUIC DATAGRAM frame each (unreliable, as UDP
|
|
22
|
+
// is), and otherwise — HTTP/2, HTTP/1.1, or an HTTP/3 peer that did not
|
|
23
|
+
// negotiate datagrams — DATAGRAM capsules on the request stream.
|
|
24
|
+
//
|
|
25
|
+
// This file is the protocol and the policy; it never touches a frame. Each
|
|
26
|
+
// HTTP version hands it a carrier (h3_server, server.js for HTTP/2 and
|
|
27
|
+
// HTTP/1.1) with five things it can do — accept, reject, send a payload to
|
|
28
|
+
// the client, end, abort — and three it reports: a datagram, stream bytes
|
|
29
|
+
// (capsules), the client's end or a reset. So the parsing, the relay, the
|
|
30
|
+
// limits and the figures are written once for all three.
|
|
31
|
+
//
|
|
32
|
+
// The application decides (server.on('connectUdp', (req, tunnel) => …)):
|
|
33
|
+
//
|
|
34
|
+
// tunnel.accept() the proxy relays, to the target
|
|
35
|
+
// the request names
|
|
36
|
+
// tunnel.accept({ target: {host, port} }) …to another one
|
|
37
|
+
// tunnel.accept({ socket }) …through the application's own
|
|
38
|
+
// dgram socket
|
|
39
|
+
// tunnel.accept({ relay: false }) no relay: the application gets
|
|
40
|
+
// the client's payloads ('message')
|
|
41
|
+
// and answers with tunnel.send()
|
|
42
|
+
// tunnel.reject(status)
|
|
43
|
+
//
|
|
44
|
+
// Defaults that protect the network the proxy sits in (RFC 9298 §7): a
|
|
45
|
+
// target that resolves only to a private, loopback, link-local, multicast
|
|
46
|
+
// or otherwise special address is refused (403, destination_ip_prohibited)
|
|
47
|
+
// unless allowed; a connection may hold a bounded number of tunnels; a
|
|
48
|
+
// tunnel with nothing passing for idleTimeout is closed (never under two
|
|
49
|
+
// minutes, §3.1).
|
|
50
|
+
// ============================================================
|
|
51
|
+
|
|
52
|
+
import { EventEmitter } from 'node:events';
|
|
53
|
+
import dgram from 'node:dgram';
|
|
54
|
+
import dns from 'node:dns';
|
|
55
|
+
import net from 'node:net';
|
|
56
|
+
import { readVarInt, writeVarInt } from './utils.js';
|
|
57
|
+
|
|
58
|
+
var DEFAULT_TEMPLATE = '/.well-known/masque/udp/{target_host}/{target_port}/';
|
|
59
|
+
var MIN_IDLE_MS = 120000; // §3.1: "SHOULD NOT use a period lower than two minutes"
|
|
60
|
+
var DEFAULT_MAX_PER_CONNECTION = 16;
|
|
61
|
+
var MAX_UDP_PAYLOAD = 65527; // §5: the largest a UDP payload can be
|
|
62
|
+
var CAPSULE_DATAGRAM = 0x00; // RFC 9297 §3.5
|
|
63
|
+
var EARLY_PAYLOADS_MAX = 32; // payloads the client sent before the tunnel opened
|
|
64
|
+
var STREAM_QUEUE_MAX = 256 * 1024; // capsules waiting to be sent before we drop (UDP's way)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
// ============================================================
|
|
68
|
+
// The target, from the request's path — the URI template (RFC 6570)
|
|
69
|
+
//
|
|
70
|
+
// The proxy's template is what its clients are configured with; the
|
|
71
|
+
// default is the one RFC 9298 §3 suggests. Two forms are read: variables in
|
|
72
|
+
// the path ('{target_host}'), and a query expansion ('{?target_host,
|
|
73
|
+
// target_port}'), whose parameters may come in any order. Values are
|
|
74
|
+
// percent-decoded; an IPv6 literal arrives with its colons encoded (§3).
|
|
75
|
+
// ============================================================
|
|
76
|
+
|
|
77
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
78
|
+
|
|
79
|
+
function compileTemplate(template) {
|
|
80
|
+
template = String(template || DEFAULT_TEMPLATE);
|
|
81
|
+
// An absolute template names the proxy's origin too; only its path and
|
|
82
|
+
// query are matched here (the authority is the connection's).
|
|
83
|
+
var m = /^[a-z][a-z0-9+.-]*:\/\/[^/]*(.*)$/i.exec(template);
|
|
84
|
+
if (m) template = m[1] || '/';
|
|
85
|
+
var query = null;
|
|
86
|
+
var qm = /\{\?([^}]*)\}$/.exec(template);
|
|
87
|
+
if (qm) { query = qm[1].split(',').map(function (s) { return s.trim(); }); template = template.slice(0, qm.index); }
|
|
88
|
+
var names = [], re = '';
|
|
89
|
+
var parts = template.split(/(\{[^}]*\})/);
|
|
90
|
+
for (var i = 0; i < parts.length; i++) {
|
|
91
|
+
var p = parts[i];
|
|
92
|
+
if (!p) continue;
|
|
93
|
+
if (p.charAt(0) === '{') {
|
|
94
|
+
var name = p.slice(1, -1).trim();
|
|
95
|
+
names.push(name);
|
|
96
|
+
re += '([^/?#&]*)';
|
|
97
|
+
} else re += escapeRe(p);
|
|
98
|
+
}
|
|
99
|
+
// A trailing slash is the template's — a client that left it off means the
|
|
100
|
+
// same resource.
|
|
101
|
+
if (/\/$/.test(re)) re += '?';
|
|
102
|
+
var vars = names.concat(query || []);
|
|
103
|
+
if (vars.indexOf('target_host') < 0 || vars.indexOf('target_port') < 0) {
|
|
104
|
+
throw new TypeError('connectUdp.template must contain {target_host} and {target_port}');
|
|
105
|
+
}
|
|
106
|
+
return { path: new RegExp('^' + re + (query ? '(?:\\?(.*))?' : '(?:\\?.*)?') + '$'), names: names, query: query };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function decode(s) {
|
|
110
|
+
try { return decodeURIComponent(s); } catch (e) { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// { host, port } — or { error: 404 } when the path is not this proxy's
|
|
114
|
+
// resource, { error: 400 } when it is, with a target that cannot be (§3:
|
|
115
|
+
// empty, a port outside 1..65535).
|
|
116
|
+
function parseTarget(compiled, url) {
|
|
117
|
+
var m = compiled.path.exec(String(url || ''));
|
|
118
|
+
if (!m) return { error: 404 };
|
|
119
|
+
var vars = {};
|
|
120
|
+
for (var i = 0; i < compiled.names.length; i++) vars[compiled.names[i]] = m[i + 1];
|
|
121
|
+
if (compiled.query) {
|
|
122
|
+
var qs = m[compiled.names.length + 1] || '';
|
|
123
|
+
qs.split('&').forEach(function (kv) {
|
|
124
|
+
var eq = kv.indexOf('=');
|
|
125
|
+
if (eq > 0) vars[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
var host = vars.target_host != null ? decode(vars.target_host) : null;
|
|
129
|
+
var portStr = vars.target_port != null ? decode(vars.target_port) : null;
|
|
130
|
+
if (!host || !portStr || !/^[0-9]{1,5}$/.test(portStr)) return { error: 400 };
|
|
131
|
+
var port = Number(portStr);
|
|
132
|
+
if (port < 1 || port > 65535) return { error: 400 };
|
|
133
|
+
if (host.charAt(0) === '[' && host.charAt(host.length - 1) === ']') host = host.slice(1, -1);
|
|
134
|
+
if (host.indexOf('%') >= 0 && net.isIPv6(host.split('%')[0])) return { error: 400 }; // §3: no zone identifiers
|
|
135
|
+
return { host: host, port: port };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// ============================================================
|
|
140
|
+
// Addresses a proxy should not reach for a stranger (RFC 9298 §7)
|
|
141
|
+
//
|
|
142
|
+
// Its own and its network's: loopback, private ranges, link-local,
|
|
143
|
+
// carrier-grade NAT, multicast and broadcast, the unspecified address, and
|
|
144
|
+
// the IPv6 forms of all of these (unique-local, an IPv4-mapped address).
|
|
145
|
+
// ============================================================
|
|
146
|
+
|
|
147
|
+
var blocked = new net.BlockList();
|
|
148
|
+
[['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
149
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
150
|
+
['224.0.0.0', 4], ['240.0.0.0', 4]].forEach(function (r) { blocked.addSubnet(r[0], r[1], 'ipv4'); });
|
|
151
|
+
[['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]].forEach(function (r) { blocked.addSubnet(r[0], r[1], 'ipv6'); });
|
|
152
|
+
|
|
153
|
+
function isPrivateAddress(ip) {
|
|
154
|
+
if (net.isIPv4(ip)) return blocked.check(ip, 'ipv4');
|
|
155
|
+
if (!net.isIPv6(ip)) return true;
|
|
156
|
+
// An IPv4 address in IPv6 clothing (::ffff:10.0.0.1, or in hex) is that
|
|
157
|
+
// IPv4 address.
|
|
158
|
+
var low = ip.toLowerCase();
|
|
159
|
+
var mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(low);
|
|
160
|
+
if (mapped) return isPrivateAddress(mapped[1]);
|
|
161
|
+
var hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(low);
|
|
162
|
+
if (hex) {
|
|
163
|
+
var a = parseInt(hex[1], 16), b = parseInt(hex[2], 16);
|
|
164
|
+
return isPrivateAddress([a >> 8, a & 255, b >> 8, b & 255].join('.'));
|
|
165
|
+
}
|
|
166
|
+
return blocked.check(ip, 'ipv6');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
// ============================================================
|
|
171
|
+
// HTTP Datagrams and capsules (RFC 9297)
|
|
172
|
+
// ============================================================
|
|
173
|
+
|
|
174
|
+
// A UDP payload as an HTTP Datagram's payload: Context ID 0, then the bytes
|
|
175
|
+
// (§4 of RFC 9298).
|
|
176
|
+
function contextZero(payload) {
|
|
177
|
+
var out = new Uint8Array(1 + payload.byteLength);
|
|
178
|
+
out[0] = 0;
|
|
179
|
+
out.set(payload, 1);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// …and as a DATAGRAM capsule: type, length, value.
|
|
184
|
+
function datagramCapsule(payload) {
|
|
185
|
+
var len = writeVarInt(1 + payload.byteLength);
|
|
186
|
+
var out = new Uint8Array(1 + len.byteLength + 1 + payload.byteLength);
|
|
187
|
+
out[0] = CAPSULE_DATAGRAM;
|
|
188
|
+
out.set(len, 1);
|
|
189
|
+
out[1 + len.byteLength] = 0;
|
|
190
|
+
out.set(payload, 2 + len.byteLength);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An HTTP Datagram's payload → the UDP payload in it, or null: a Context ID
|
|
195
|
+
// other than 0 is one nobody registered here (§4: dropped).
|
|
196
|
+
function udpPayloadOf(u8) {
|
|
197
|
+
var c = readVarInt(u8, 0);
|
|
198
|
+
if (!c || c.value !== 0) return null;
|
|
199
|
+
return u8.subarray(c.byteLength);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Capsules from the request stream, however the bytes are split. DATAGRAM
|
|
203
|
+
// capsules go to onDatagram (their value: an HTTP Datagram payload); a
|
|
204
|
+
// capsule of another type is skipped (RFC 9297 §3.2: unknown types are
|
|
205
|
+
// ignored), and so is a DATAGRAM too large to carry a UDP payload — without
|
|
206
|
+
// buffering it (§5 of RFC 9298), so neither costs memory.
|
|
207
|
+
function createCapsuleParser(onDatagram, onDropped) {
|
|
208
|
+
var pending = null, skip = 0;
|
|
209
|
+
var MAX_VALUE = MAX_UDP_PAYLOAD + 8;
|
|
210
|
+
return function feed(chunk) {
|
|
211
|
+
if (!chunk || chunk.byteLength === 0) return;
|
|
212
|
+
var u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
213
|
+
while (u8.byteLength > 0) {
|
|
214
|
+
if (skip > 0) {
|
|
215
|
+
var n = Math.min(skip, u8.byteLength);
|
|
216
|
+
skip -= n;
|
|
217
|
+
u8 = u8.subarray(n);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (pending) {
|
|
221
|
+
var joined = new Uint8Array(pending.byteLength + u8.byteLength);
|
|
222
|
+
joined.set(pending, 0); joined.set(u8, pending.byteLength);
|
|
223
|
+
u8 = joined; pending = null;
|
|
224
|
+
}
|
|
225
|
+
for (;;) {
|
|
226
|
+
var t = readVarInt(u8, 0);
|
|
227
|
+
var l = t && readVarInt(u8, t.byteLength);
|
|
228
|
+
if (!t || !l) { pending = u8.byteLength ? u8 : null; u8 = new Uint8Array(0); break; }
|
|
229
|
+
var hdr = t.byteLength + l.byteLength, len = l.value;
|
|
230
|
+
if (t.value !== CAPSULE_DATAGRAM || len > MAX_VALUE) {
|
|
231
|
+
if (t.value === CAPSULE_DATAGRAM && onDropped) onDropped();
|
|
232
|
+
var avail = u8.byteLength - hdr;
|
|
233
|
+
if (avail >= len) { u8 = u8.subarray(hdr + len); continue; }
|
|
234
|
+
skip = len - avail;
|
|
235
|
+
u8 = new Uint8Array(0);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (u8.byteLength < hdr + len) { pending = u8; u8 = new Uint8Array(0); break; }
|
|
239
|
+
onDatagram(u8.subarray(hdr, hdr + len));
|
|
240
|
+
u8 = u8.subarray(hdr + len);
|
|
241
|
+
if (u8.byteLength === 0) break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
// ============================================================
|
|
249
|
+
// The manager: one per server
|
|
250
|
+
//
|
|
251
|
+
// Holds the policy (options.connectUdp) and the figures across tunnels;
|
|
252
|
+
// handle(req, carrier, emit) is what each HTTP version calls with a
|
|
253
|
+
// CONNECT-UDP request.
|
|
254
|
+
// ============================================================
|
|
255
|
+
|
|
256
|
+
function createConnectUdp(options) {
|
|
257
|
+
options = options || {};
|
|
258
|
+
var compiled = compileTemplate(options.template);
|
|
259
|
+
var allowPrivateDefault = options.allowPrivate === true;
|
|
260
|
+
var maxPerConnection = options.maxTunnelsPerConnection > 0 ? Math.floor(options.maxTunnelsPerConnection) : DEFAULT_MAX_PER_CONNECTION;
|
|
261
|
+
var idleTimeout = options.idleTimeout === 0 ? 0 : Math.max(MIN_IDLE_MS, Number(options.idleTimeout) || MIN_IDLE_MS);
|
|
262
|
+
var lookup = typeof options.lookup === 'function' ? options.lookup : dns.lookup;
|
|
263
|
+
var proxyName = options.proxyName || 'quico';
|
|
264
|
+
|
|
265
|
+
var perConnection = new WeakMap(); // carrier.connKey → tunnels open on it
|
|
266
|
+
var active = new Set();
|
|
267
|
+
var totals = { accepted: 0, rejected: 0, closed: 0,
|
|
268
|
+
packetsFromClient: 0, bytesFromClient: 0, packetsToClient: 0, bytesToClient: 0,
|
|
269
|
+
droppedFromClient: 0, droppedToClient: 0 };
|
|
270
|
+
var COUNTERS = ['packetsFromClient', 'bytesFromClient', 'packetsToClient', 'bytesToClient', 'droppedFromClient', 'droppedToClient'];
|
|
271
|
+
|
|
272
|
+
function proxyStatus(error, details) {
|
|
273
|
+
return proxyName + '; error=' + error + (details ? '; details="' + String(details).replace(/["\\]/g, '') + '"' : '');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A CONNECT-UDP request (HTTP/2, HTTP/3) or Upgrade (HTTP/1.1). `emit`
|
|
277
|
+
// hands the tunnel to the application; false when nothing listens.
|
|
278
|
+
function handle(req, carrier, emit) {
|
|
279
|
+
var target = parseTarget(compiled, req.url);
|
|
280
|
+
if (carrier.httpVersion === '1.1' && String(req.method).toUpperCase() !== 'GET') target = { error: 400 }; // §3.2
|
|
281
|
+
if (target.error) {
|
|
282
|
+
totals.rejected++;
|
|
283
|
+
carrier.reject(target.error, null);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
var key = carrier.connKey;
|
|
287
|
+
var count = (key && perConnection.get(key)) || 0;
|
|
288
|
+
if (count >= maxPerConnection) {
|
|
289
|
+
totals.rejected++;
|
|
290
|
+
carrier.reject(429, null);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (key) perConnection.set(key, count + 1);
|
|
294
|
+
var tunnel = new UdpTunnel(req, carrier, target);
|
|
295
|
+
active.add(tunnel);
|
|
296
|
+
tunnel.once('close', function () {
|
|
297
|
+
active.delete(tunnel);
|
|
298
|
+
if (key) { var c = perConnection.get(key) || 1; if (c <= 1) perConnection.delete(key); else perConnection.set(key, c - 1); }
|
|
299
|
+
totals.closed++;
|
|
300
|
+
if (tunnel._wasOpen) totals.accepted++; else totals.rejected++;
|
|
301
|
+
for (var i = 0; i < COUNTERS.length; i++) totals[COUNTERS[i]] += tunnel._stats[COUNTERS[i]];
|
|
302
|
+
});
|
|
303
|
+
if (!emit(tunnel)) tunnel.reject(501);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The figures: tunnels open now, and totals — those of open tunnels
|
|
307
|
+
// included.
|
|
308
|
+
function stats() {
|
|
309
|
+
var out = { active: active.size, accepted: totals.accepted, rejected: totals.rejected, closed: totals.closed };
|
|
310
|
+
for (var i = 0; i < COUNTERS.length; i++) out[COUNTERS[i]] = totals[COUNTERS[i]];
|
|
311
|
+
active.forEach(function (t) {
|
|
312
|
+
if (t._wasOpen && t.state === 'open') out.accepted++;
|
|
313
|
+
for (var i = 0; i < COUNTERS.length; i++) out[COUNTERS[i]] += t._stats[COUNTERS[i]];
|
|
314
|
+
});
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
// ============================================================
|
|
320
|
+
// A tunnel
|
|
321
|
+
// ============================================================
|
|
322
|
+
|
|
323
|
+
function UdpTunnel(req, carrier, target) {
|
|
324
|
+
EventEmitter.call(this);
|
|
325
|
+
var self = this;
|
|
326
|
+
this.req = req;
|
|
327
|
+
this.target = { host: target.host, port: target.port };
|
|
328
|
+
this.httpVersion = carrier.httpVersion;
|
|
329
|
+
this.state = 'pending'; // → 'open' → 'closed'
|
|
330
|
+
this.error = null; // why it was refused or ended, when it was not the client
|
|
331
|
+
this.closeReason = null; // 'client', 'aborted', 'idle', 'local', 'socket', 'rejected'
|
|
332
|
+
this._carrier = carrier;
|
|
333
|
+
this._wasOpen = false;
|
|
334
|
+
this._relay = null; // { socket, own, address, port, onMessage, onError }
|
|
335
|
+
this._early = []; // payloads from the client before 'open'
|
|
336
|
+
this._last = Date.now();
|
|
337
|
+
this._idleTimer = null;
|
|
338
|
+
this._stats = { openedAt: null, closedAt: null, address: null, port: null,
|
|
339
|
+
packetsFromClient: 0, bytesFromClient: 0, packetsToClient: 0, bytesToClient: 0,
|
|
340
|
+
droppedFromClient: 0, droppedToClient: 0 };
|
|
341
|
+
carrier.sink = {
|
|
342
|
+
datagram: function (u8) { self._fromClient(udpPayloadOf(u8)); },
|
|
343
|
+
data: createCapsuleParser(function (u8) { self._fromClient(udpPayloadOf(u8)); },
|
|
344
|
+
function () { self._stats.droppedFromClient++; }),
|
|
345
|
+
end: function () { self._ended('client'); },
|
|
346
|
+
abort: function () { self._ended('aborted'); }
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
Object.setPrototypeOf(UdpTunnel.prototype, EventEmitter.prototype);
|
|
350
|
+
|
|
351
|
+
// A copy of the figures, with how the datagrams travel to the client now:
|
|
352
|
+
// 'datagram' (QUIC DATAGRAM frames) or 'capsule' (the request stream).
|
|
353
|
+
Object.defineProperty(UdpTunnel.prototype, 'stats', { get: function () {
|
|
354
|
+
var s = Object.assign({ target: { host: this.target.host, port: this.target.port }, httpVersion: this.httpVersion,
|
|
355
|
+
state: this.state, transport: this._carrier.mode ? this._carrier.mode() : 'capsule' }, this._stats);
|
|
356
|
+
s.durationMs = s.openedAt ? (s.closedAt || Date.now()) - s.openedAt : 0;
|
|
357
|
+
return s;
|
|
358
|
+
} });
|
|
359
|
+
|
|
360
|
+
UdpTunnel.prototype.accept = function (opts) {
|
|
361
|
+
if (this.state !== 'pending' || this._accepting) return this;
|
|
362
|
+
opts = opts || {};
|
|
363
|
+
var self = this;
|
|
364
|
+
this._accepting = true;
|
|
365
|
+
if (opts.target) {
|
|
366
|
+
var p = Number(opts.target.port);
|
|
367
|
+
if (!opts.target.host || !(p >= 1 && p <= 65535)) throw new TypeError('accept({ target }) needs a host and a port in 1..65535');
|
|
368
|
+
this.target = { host: String(opts.target.host), port: p };
|
|
369
|
+
}
|
|
370
|
+
var headers = opts.headers || null;
|
|
371
|
+
if (opts.relay === false) { this._open(headers); return this; }
|
|
372
|
+
|
|
373
|
+
var allowPrivate = opts.allowPrivate != null ? !!opts.allowPrivate : allowPrivateDefault;
|
|
374
|
+
var host = this.target.host;
|
|
375
|
+
lookup(host, { all: true }, function (err, list) {
|
|
376
|
+
if (self.state !== 'pending') return;
|
|
377
|
+
if (err || !list || !list.length) {
|
|
378
|
+
self._refuse(502, proxyStatus('dns_error', err && err.code), err || new Error('no address for ' + host));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(list)) list = [{ address: list, family: net.isIPv6(list) ? 6 : 4 }];
|
|
382
|
+
var pick = null;
|
|
383
|
+
for (var i = 0; i < list.length && !pick; i++) if (allowPrivate || !isPrivateAddress(list[i].address)) pick = list[i];
|
|
384
|
+
if (!pick) {
|
|
385
|
+
var e = new Error('target ' + host + ' resolves only to addresses not allowed'); e.code = 'ERR_DESTINATION_PROHIBITED';
|
|
386
|
+
self._refuse(403, proxyStatus('destination_ip_prohibited'), e);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
var family = pick.family === 6 || pick.family === 'IPv6' ? 6 : 4;
|
|
390
|
+
if (opts.socket) self._useSocket(opts.socket, false, pick.address, headers);
|
|
391
|
+
else {
|
|
392
|
+
var sock;
|
|
393
|
+
try { sock = dgram.createSocket(family === 6 ? 'udp6' : 'udp4'); }
|
|
394
|
+
catch (e2) { self._refuse(502, proxyStatus('proxy_internal_error'), e2); return; }
|
|
395
|
+
sock.once('error', function (e3) {
|
|
396
|
+
if (self.state === 'pending') self._refuse(502, proxyStatus('destination_ip_unroutable', e3.code), e3);
|
|
397
|
+
});
|
|
398
|
+
sock.connect(self.target.port, pick.address, function () {
|
|
399
|
+
if (self.state !== 'pending') { try { sock.close(); } catch (e4) {} return; }
|
|
400
|
+
self._useSocket(sock, true, pick.address, headers);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
return this;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// The relay's socket: ours (connected to the target — the kernel keeps
|
|
408
|
+
// other senders out), or the application's, whose senders are checked
|
|
409
|
+
// here (§5: "MUST validate the IP source address and UDP source port").
|
|
410
|
+
UdpTunnel.prototype._useSocket = function (sock, own, address, headers) {
|
|
411
|
+
var self = this, port = this.target.port;
|
|
412
|
+
var connected = own;
|
|
413
|
+
if (!own) { try { connected = !!sock.remoteAddress(); } catch (e) { connected = false; } }
|
|
414
|
+
var relay = { socket: sock, own: own, connected: connected, address: address, port: port };
|
|
415
|
+
relay.onMessage = function (msg, rinfo) {
|
|
416
|
+
if (!relay.connected && (rinfo.port !== port || rinfo.address !== address)) return;
|
|
417
|
+
self._toClient(msg);
|
|
418
|
+
};
|
|
419
|
+
// The socket can no longer be used — an ICMP port unreachable, on a
|
|
420
|
+
// connected socket: the request stream closes with it (§3.1).
|
|
421
|
+
relay.onError = function (err) {
|
|
422
|
+
if (self.state === 'closed') return;
|
|
423
|
+
self.error = err;
|
|
424
|
+
self._close('socket');
|
|
425
|
+
};
|
|
426
|
+
sock.on('message', relay.onMessage);
|
|
427
|
+
sock.on('error', relay.onError);
|
|
428
|
+
this._relay = relay;
|
|
429
|
+
this._stats.address = address; this._stats.port = port;
|
|
430
|
+
this._open(headers);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
UdpTunnel.prototype._open = function (headers) {
|
|
434
|
+
var h = { 'capsule-protocol': '?1' };
|
|
435
|
+
for (var n in (headers || {})) h[String(n).toLowerCase()] = headers[n];
|
|
436
|
+
this.state = 'open';
|
|
437
|
+
this._wasOpen = true;
|
|
438
|
+
this._stats.openedAt = Date.now();
|
|
439
|
+
this._carrier.accept(h);
|
|
440
|
+
this._touch();
|
|
441
|
+
this._armIdle();
|
|
442
|
+
this.emit('open');
|
|
443
|
+
var early = this._early; this._early = null;
|
|
444
|
+
for (var i = 0; early && i < early.length && this.state === 'open'; i++) this._deliver(early[i]);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
UdpTunnel.prototype._refuse = function (status, ps, err) {
|
|
448
|
+
if (this.state !== 'pending') return;
|
|
449
|
+
this.error = err || null;
|
|
450
|
+
this._carrier.reject(status, ps ? { 'proxy-status': ps } : null);
|
|
451
|
+
this._close('rejected');
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
UdpTunnel.prototype.reject = function (status, headers) {
|
|
455
|
+
if (this.state !== 'pending') return this;
|
|
456
|
+
this._carrier.reject(status || 403, headers || null);
|
|
457
|
+
this._close('rejected');
|
|
458
|
+
return this;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// A UDP payload to the client — the target's (the relay), or the
|
|
462
|
+
// application's (tunnel.send). false: not sent (closed, too large for the
|
|
463
|
+
// path, or the stream already holds too much — UDP's answer to a full
|
|
464
|
+
// queue is to drop).
|
|
465
|
+
UdpTunnel.prototype.send = function (data) {
|
|
466
|
+
if (typeof data === 'string') data = Buffer.from(data);
|
|
467
|
+
if (!(data instanceof Uint8Array)) throw new TypeError('tunnel.send() takes a Buffer, a Uint8Array or a string');
|
|
468
|
+
return this._toClient(data);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
UdpTunnel.prototype._toClient = function (u8) {
|
|
472
|
+
if (this.state !== 'open') return false;
|
|
473
|
+
if (u8.byteLength > MAX_UDP_PAYLOAD || !this._carrier.sendPayload(u8)) { this._stats.droppedToClient++; return false; }
|
|
474
|
+
this._stats.packetsToClient++;
|
|
475
|
+
this._stats.bytesToClient += u8.byteLength;
|
|
476
|
+
this._touch();
|
|
477
|
+
return true;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
UdpTunnel.prototype._fromClient = function (payload) {
|
|
481
|
+
if (this.state === 'closed') return;
|
|
482
|
+
if (!payload || payload.byteLength > MAX_UDP_PAYLOAD) { this._stats.droppedFromClient++; return; }
|
|
483
|
+
if (this.state === 'pending') {
|
|
484
|
+
// §3.4: a client may send before it has the 2xx. Kept, a few — they go
|
|
485
|
+
// once the tunnel opens.
|
|
486
|
+
if (this._early.length >= EARLY_PAYLOADS_MAX) { this._stats.droppedFromClient++; return; }
|
|
487
|
+
this._early.push(Uint8Array.prototype.slice.call(payload));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this._deliver(payload);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
UdpTunnel.prototype._deliver = function (payload) {
|
|
494
|
+
this._stats.packetsFromClient++;
|
|
495
|
+
this._stats.bytesFromClient += payload.byteLength;
|
|
496
|
+
this._touch();
|
|
497
|
+
var relay = this._relay;
|
|
498
|
+
if (!relay) { this.emit('message', Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)); return; }
|
|
499
|
+
var self = this;
|
|
500
|
+
var cb = function (err) { if (err) self._stats.droppedFromClient++; };
|
|
501
|
+
try {
|
|
502
|
+
if (relay.connected) relay.socket.send(payload, cb);
|
|
503
|
+
else relay.socket.send(payload, relay.port, relay.address, cb);
|
|
504
|
+
} catch (e) { this._stats.droppedFromClient++; }
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
UdpTunnel.prototype._touch = function () { this._last = Date.now(); };
|
|
508
|
+
|
|
509
|
+
// One timer for the tunnel's life, not one per packet: when it fires it
|
|
510
|
+
// looks at the last activity, and sleeps for what is left.
|
|
511
|
+
UdpTunnel.prototype._armIdle = function () {
|
|
512
|
+
if (!idleTimeout || this.state !== 'open') return;
|
|
513
|
+
var self = this, wait = Math.max(1000, this._last + idleTimeout - Date.now());
|
|
514
|
+
this._idleTimer = setTimeout(function () {
|
|
515
|
+
self._idleTimer = null;
|
|
516
|
+
if (self.state !== 'open') return;
|
|
517
|
+
if (Date.now() - self._last >= idleTimeout) self._close('idle');
|
|
518
|
+
else self._armIdle();
|
|
519
|
+
}, wait);
|
|
520
|
+
if (this._idleTimer.unref) this._idleTimer.unref();
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
UdpTunnel.prototype.close = function () {
|
|
524
|
+
if (this.state === 'closed') return this;
|
|
525
|
+
if (this.state === 'pending') return this.reject(503);
|
|
526
|
+
this._close('local');
|
|
527
|
+
return this;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// The client ended its side, or the stream went.
|
|
531
|
+
UdpTunnel.prototype._ended = function (reason) {
|
|
532
|
+
if (this.state === 'closed') return;
|
|
533
|
+
this._close(reason);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
UdpTunnel.prototype._close = function (reason) {
|
|
537
|
+
if (this.state === 'closed') return;
|
|
538
|
+
var wasPending = this.state === 'pending';
|
|
539
|
+
this.state = 'closed';
|
|
540
|
+
this.closeReason = reason;
|
|
541
|
+
this._stats.closedAt = Date.now();
|
|
542
|
+
this._early = null;
|
|
543
|
+
if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; }
|
|
544
|
+
var relay = this._relay;
|
|
545
|
+
this._relay = null;
|
|
546
|
+
if (relay) {
|
|
547
|
+
relay.socket.removeListener('message', relay.onMessage);
|
|
548
|
+
relay.socket.removeListener('error', relay.onError);
|
|
549
|
+
if (relay.own) { relay.socket.on('error', function () {}); try { relay.socket.close(); } catch (e) {} }
|
|
550
|
+
}
|
|
551
|
+
// The request stream with it (§3.1): ended (FIN) after the client's
|
|
552
|
+
// end, ours (close()) or an idle one; reset when it broke — the socket
|
|
553
|
+
// failed, the stream was reset, or the client left before any answer.
|
|
554
|
+
// A refusal has sent its answer already.
|
|
555
|
+
if (reason !== 'rejected') {
|
|
556
|
+
if (wasPending || reason === 'aborted' || reason === 'socket') this._carrier.abort();
|
|
557
|
+
else this._carrier.end();
|
|
558
|
+
}
|
|
559
|
+
this.emit('close', this.stats);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
return { handle: handle, stats: stats, compiled: compiled };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
// Is this request a CONNECT-UDP? Over HTTP/2 and HTTP/3, a CONNECT with
|
|
567
|
+
// :protocol connect-udp; over HTTP/1.1, an Upgrade to it (§3.2).
|
|
568
|
+
function isConnectUdp(req) {
|
|
569
|
+
var h = (req && req.headers) || {};
|
|
570
|
+
if (h[':protocol'] != null) return String(h[':protocol']).toLowerCase() === 'connect-udp';
|
|
571
|
+
var up = h.upgrade;
|
|
572
|
+
if (up == null) return false;
|
|
573
|
+
return String(up).split(',').some(function (t) { return t.trim().toLowerCase() === 'connect-udp'; });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
export { createConnectUdp, isConnectUdp, parseTarget, compileTemplate, isPrivateAddress,
|
|
578
|
+
createCapsuleParser, datagramCapsule, contextZero, udpPayloadOf, MAX_UDP_PAYLOAD, STREAM_QUEUE_MAX };
|