quico 0.2.0 → 0.3.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/index.js +5 -2
- package/package.json +2 -2
- package/src/agent.js +9 -1
- package/src/client.js +6 -1
- package/src/crypto.js +20 -5
- package/src/h3.js +52 -43
- package/src/h3_client.js +93 -78
- package/src/h3_server.js +52 -183
- package/src/quic_connection.js +1221 -121
- package/src/quic_server.js +314 -0
- package/src/quic_socket.js +76 -0
- package/src/server.js +30 -22
- package/src/tls_bridge.js +74 -12
- package/src/transport.js +50 -34
- package/src/utils.js +59 -4
- package/src/webtransport.js +25 -78
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
import dgram from 'node:dgram';
|
|
16
|
+
import { DEBUG, Emitter } from './utils.js';
|
|
17
|
+
import { parse_quic_datagram } from './transport.js';
|
|
18
|
+
import { QUICConnection } from './quic_connection.js';
|
|
19
|
+
import { createSecureContext } from './tls_bridge.js';
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// createQuicServer(options)
|
|
24
|
+
//
|
|
25
|
+
// Transport-only QUIC server. Owns the UDP socket(s), demultiplexes
|
|
26
|
+
// incoming datagrams to per-peer QUICConnection objects, and emits a
|
|
27
|
+
// 'connection' event carrying the raw QUICConnection. It knows nothing
|
|
28
|
+
// about HTTP/3 — application protocols (HTTP/3, DNS-over-QUIC, ...) are
|
|
29
|
+
// layered on top by listening for 'connection' and then using the
|
|
30
|
+
// connection's own stream/datagram API.
|
|
31
|
+
//
|
|
32
|
+
// This is the connection-management harness that used to live inside
|
|
33
|
+
// h3_server.js. h3_server now builds on it (wrapping each connection in
|
|
34
|
+
// an H3Connection); other consumers (e.g. a DoQ server) can use it
|
|
35
|
+
// directly.
|
|
36
|
+
//
|
|
37
|
+
// Events:
|
|
38
|
+
// 'connection' (quic, peer) — new QUICConnection created for a peer.
|
|
39
|
+
// peer = { address, port }. Handlers
|
|
40
|
+
// attach quic.on('connect'|'stream'|
|
|
41
|
+
// 'datagram'|'close') as needed.
|
|
42
|
+
// 'error' (err) — UDP socket / send error.
|
|
43
|
+
//
|
|
44
|
+
// Options:
|
|
45
|
+
// alpn — ALPN protocol(s) to advertise (default 'h3').
|
|
46
|
+
// DoQ servers pass 'doq'.
|
|
47
|
+
// SNICallback — TLS SNI callback returning a SecureContext.
|
|
48
|
+
// key/cert/ca — convenience: build a default SecureContext when no
|
|
49
|
+
// SNICallback is given.
|
|
50
|
+
// maxConnections — capacity cap (default 10000).
|
|
51
|
+
// socket/socket6 — externally-owned, already-bound UDP sockets
|
|
52
|
+
// (shared-port mode, RFC 9443). When provided, the
|
|
53
|
+
// caller feeds datagrams via handlePacket(msg, rinfo).
|
|
54
|
+
// ============================================================
|
|
55
|
+
|
|
56
|
+
function createQuicServer(options) {
|
|
57
|
+
options = options || {};
|
|
58
|
+
|
|
59
|
+
var ev = Emitter();
|
|
60
|
+
var udp4 = null;
|
|
61
|
+
var udp6 = null;
|
|
62
|
+
var port = null;
|
|
63
|
+
|
|
64
|
+
// ALPN to advertise on every accepted connection. Default ['h3'].
|
|
65
|
+
var alpn = options.alpn || ['h3'];
|
|
66
|
+
|
|
67
|
+
// ── External socket mode (shared UDP port) ──
|
|
68
|
+
// When socket/socket6 are provided, the UDP layer is owned externally.
|
|
69
|
+
// The caller demuxes packets and feeds them via handlePacket(msg, rinfo).
|
|
70
|
+
// See RFC 9443 for the shared-port multiplexing scheme.
|
|
71
|
+
var externalSocket4 = options.socket || null;
|
|
72
|
+
var externalSocket6 = options.socket6 || null;
|
|
73
|
+
|
|
74
|
+
if (externalSocket4) {
|
|
75
|
+
try { externalSocket4.address(); }
|
|
76
|
+
catch (e) { throw new Error('options.socket must be bound before createQuicServer()'); }
|
|
77
|
+
}
|
|
78
|
+
if (externalSocket6) {
|
|
79
|
+
try { externalSocket6.address(); }
|
|
80
|
+
catch (e) { throw new Error('options.socket6 must be bound before createQuicServer()'); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
var isExternal = !!(externalSocket4 || externalSocket6);
|
|
84
|
+
|
|
85
|
+
// Build SNICallback
|
|
86
|
+
var sniCallback = options.SNICallback || null;
|
|
87
|
+
|
|
88
|
+
if (!sniCallback && options.key && options.cert) {
|
|
89
|
+
var defaultCtx = createSecureContext({ key: options.key, cert: options.cert, ca: options.ca });
|
|
90
|
+
sniCallback = function (servername, cb) { cb(null, defaultCtx); };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Connection tracking ----
|
|
94
|
+
var connections = {}; // connId → { quic, peer }
|
|
95
|
+
var addressMap = {}; // "ip:port" → connId
|
|
96
|
+
var maxConnections = options.maxConnections || 10000;
|
|
97
|
+
var sweepTimer = null;
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// ============================================================
|
|
101
|
+
// UDP → connection routing
|
|
102
|
+
// ============================================================
|
|
103
|
+
|
|
104
|
+
function onUdpMessage(msg, rinfo) {
|
|
105
|
+
var data = new Uint8Array(msg);
|
|
106
|
+
var fromIp = rinfo.address;
|
|
107
|
+
var fromPort = rinfo.port;
|
|
108
|
+
if (DEBUG) console.log('[qserver] UDP from ' + fromIp + ':' + fromPort + ' len=' + data.length);
|
|
109
|
+
|
|
110
|
+
var addressKey = fromIp + ':' + fromPort;
|
|
111
|
+
var packets = parse_quic_datagram(data);
|
|
112
|
+
if (packets.length === 0) return;
|
|
113
|
+
|
|
114
|
+
// Route the whole datagram by its first packet. Coalesced packets in one
|
|
115
|
+
// UDP datagram belong to the same connection (RFC 9000 §12.2), so a single
|
|
116
|
+
// routing decision covers them all — and the datagram is parsed only once
|
|
117
|
+
// here, handing the parsed packets straight to the connection (previously
|
|
118
|
+
// each packet's raw bytes were re-parsed inside feedDatagram).
|
|
119
|
+
var connId = findConnectionId(packets[0], addressKey);
|
|
120
|
+
if (connId === null) return; // stray/unmatched non-Initial packet — drop
|
|
121
|
+
|
|
122
|
+
if (!(connId in connections)) {
|
|
123
|
+
// Reject new connections when at capacity
|
|
124
|
+
if (Object.keys(connections).length >= maxConnections) return;
|
|
125
|
+
createConnection(connId, fromIp, fromPort, addressKey);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
connections[connId].quic.feedPackets(fromIp, fromPort, packets);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
function findConnectionId(pkt, addressKey) {
|
|
133
|
+
var dcidStr = null;
|
|
134
|
+
if (pkt.dcid && pkt.dcid.byteLength > 0) {
|
|
135
|
+
dcidStr = Array.from(pkt.dcid).join('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (dcidStr && dcidStr in connections) return dcidStr;
|
|
139
|
+
if (addressKey in addressMap && addressMap[addressKey] in connections) return addressMap[addressKey];
|
|
140
|
+
|
|
141
|
+
// No existing connection matches. Only an Initial packet may *start* a new
|
|
142
|
+
// connection; any other unmatched packet (a stray/late short-header packet,
|
|
143
|
+
// or a Handshake whose Initial was lost, etc.) is dropped rather than
|
|
144
|
+
// spawning a phantom connection keyed by a random id.
|
|
145
|
+
if (pkt.type !== 'initial') return null;
|
|
146
|
+
|
|
147
|
+
var id = dcidStr || String(Math.floor(Math.random() * 9007199254740991));
|
|
148
|
+
addressMap[addressKey] = id;
|
|
149
|
+
return id;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
function createConnection(connId, fromIp, fromPort, addressKey) {
|
|
154
|
+
var quic = new QUICConnection({ SNICallback: sniCallback, alpn: alpn });
|
|
155
|
+
|
|
156
|
+
connections[connId] = {
|
|
157
|
+
quic: quic,
|
|
158
|
+
peer: { address: fromIp, port: fromPort }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
addressMap[addressKey] = connId;
|
|
162
|
+
|
|
163
|
+
// Send packets back via UDP
|
|
164
|
+
quic.on('packet', function (data) {
|
|
165
|
+
sendUdp(fromIp, fromPort, data);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Drop our bookkeeping when the connection closes.
|
|
169
|
+
quic.on('close', function () {
|
|
170
|
+
delete connections[connId];
|
|
171
|
+
if (addressMap[addressKey] === connId) delete addressMap[addressKey];
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Hand the raw connection to the application layer (H3, DoQ, ...).
|
|
175
|
+
ev.emit('connection', quic, { address: fromIp, port: fromPort });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
// ============================================================
|
|
180
|
+
// UDP send
|
|
181
|
+
// ============================================================
|
|
182
|
+
|
|
183
|
+
function sendUdp(toIp, toPort, data) {
|
|
184
|
+
var isV6 = toIp.indexOf(':') >= 0;
|
|
185
|
+
// Prefer external socket (shared mode) over internally-bound socket
|
|
186
|
+
var socket = isV6
|
|
187
|
+
? (externalSocket6 || udp6)
|
|
188
|
+
: (externalSocket4 || udp4);
|
|
189
|
+
if (!socket) return;
|
|
190
|
+
socket.send(data, toPort, toIp, function (err) {
|
|
191
|
+
if (err) ev.emit('error', err);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
// ============================================================
|
|
197
|
+
// listen / close
|
|
198
|
+
// ============================================================
|
|
199
|
+
|
|
200
|
+
function listen(listenPort, host, callback) {
|
|
201
|
+
if (typeof host === 'function') { callback = host; host = null; }
|
|
202
|
+
port = listenPort || 443;
|
|
203
|
+
host = host || '::';
|
|
204
|
+
|
|
205
|
+
if (isExternal) {
|
|
206
|
+
// External sockets already bound — wire up incoming messages.
|
|
207
|
+
// Starting the sweep timer is safe here (idempotent check below).
|
|
208
|
+
if (externalSocket4) {
|
|
209
|
+
externalSocket4.on('message', onUdpMessage);
|
|
210
|
+
}
|
|
211
|
+
if (externalSocket6) {
|
|
212
|
+
externalSocket6.on('message', onUdpMessage);
|
|
213
|
+
}
|
|
214
|
+
startSweepTimer();
|
|
215
|
+
if (typeof callback === 'function') setImmediate(callback);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
udp4 = dgram.createSocket('udp4');
|
|
220
|
+
udp4.on('message', onUdpMessage);
|
|
221
|
+
udp4.on('error', function (err) { ev.emit('error', err); });
|
|
222
|
+
var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
|
|
223
|
+
udp4.bind(port, host4);
|
|
224
|
+
|
|
225
|
+
udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
|
|
226
|
+
udp6.on('message', onUdpMessage);
|
|
227
|
+
udp6.on('error', function (err) { ev.emit('error', err); });
|
|
228
|
+
var host6 = host.indexOf(':') !== -1 ? host : '::';
|
|
229
|
+
udp6.bind(port, host6, function () {
|
|
230
|
+
if (typeof callback === 'function') callback();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
startSweepTimer();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Periodic sweep for dead connections (safety net). Runs independently
|
|
237
|
+
// of socket ownership so external-mode agents get the same cleanup.
|
|
238
|
+
function startSweepTimer() {
|
|
239
|
+
if (sweepTimer) return; // idempotent
|
|
240
|
+
sweepTimer = setInterval(function () {
|
|
241
|
+
for (var id in connections) {
|
|
242
|
+
var st = connections[id].quic.state;
|
|
243
|
+
if (st === 'closed') {
|
|
244
|
+
delete connections[id];
|
|
245
|
+
// Clean address map
|
|
246
|
+
for (var addr in addressMap) {
|
|
247
|
+
if (addressMap[addr] === id) delete addressMap[addr];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}, 30000);
|
|
252
|
+
if (sweepTimer.unref) sweepTimer.unref();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function close(callback) {
|
|
256
|
+
// Stop sweep
|
|
257
|
+
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
258
|
+
|
|
259
|
+
// Close all connections
|
|
260
|
+
for (var id in connections) {
|
|
261
|
+
try { connections[id].quic.close(0, 'server closing'); } catch (e) { }
|
|
262
|
+
}
|
|
263
|
+
connections = {};
|
|
264
|
+
addressMap = {};
|
|
265
|
+
|
|
266
|
+
var closed = 0;
|
|
267
|
+
var total = (udp4 ? 1 : 0) + (udp6 ? 1 : 0);
|
|
268
|
+
if (total === 0) { if (callback) callback(); return; }
|
|
269
|
+
|
|
270
|
+
function onClose() {
|
|
271
|
+
closed++;
|
|
272
|
+
if (closed >= total && typeof callback === 'function') callback();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (udp4) { udp4.close(onClose); udp4 = null; }
|
|
276
|
+
if (udp6) { udp6.close(onClose); udp6 = null; }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
// ============================================================
|
|
281
|
+
// External packet API (for shared UDP port scenarios)
|
|
282
|
+
// ============================================================
|
|
283
|
+
|
|
284
|
+
// Query whether the given 5-tuple matches an active QUIC connection.
|
|
285
|
+
// Used by the demuxer to disambiguate byte 0 in range 64-79 between
|
|
286
|
+
// QUIC short header and TURN ChannelData (RFC 9443). Fresh connections
|
|
287
|
+
// must be classified by byte 0 range (80-127 or 192+ = QUIC).
|
|
288
|
+
function hasConnection(rinfo) {
|
|
289
|
+
var key = rinfo.address + ':' + rinfo.port;
|
|
290
|
+
return (key in addressMap) && (addressMap[key] in connections);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
// ============================================================
|
|
295
|
+
// Server API
|
|
296
|
+
// ============================================================
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
listen: listen,
|
|
300
|
+
close: close,
|
|
301
|
+
on: function (name, fn) { ev.on(name, fn); },
|
|
302
|
+
|
|
303
|
+
/** Feed an incoming UDP packet when running in external-socket mode.
|
|
304
|
+
* rinfo must be in Node's dgram format: { address, port, family, size }. */
|
|
305
|
+
handlePacket: onUdpMessage,
|
|
306
|
+
|
|
307
|
+
/** Returns true if the 5-tuple matches an active QUIC connection.
|
|
308
|
+
* Used by demuxers for routing decisions on shared UDP ports. */
|
|
309
|
+
hasConnection: hasConnection
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
export { createQuicServer };
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
import dgram from 'node:dgram';
|
|
16
|
+
import { QUICConnection } from './quic_connection.js';
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a UDP socket bound to an ephemeral port and wire it to a client-side
|
|
21
|
+
* QUICConnection. Shared by the HTTP/3 client (h3_client.js) and the
|
|
22
|
+
* WebTransport client (webtransport.js), which had near-identical setup code:
|
|
23
|
+
* pick udp4/udp6, create the socket, forward datagrams into the connection,
|
|
24
|
+
* bind, create the client QUICConnection, forward outgoing packets to the
|
|
25
|
+
* socket, and start the handshake.
|
|
26
|
+
*
|
|
27
|
+
* opts:
|
|
28
|
+
* remoteIp, remotePort — where to send packets
|
|
29
|
+
* hostname — SNI / :authority for the QUIC/TLS handshake
|
|
30
|
+
* onConnect(quic, socket) — called once the QUIC handshake completes
|
|
31
|
+
* onClose() — called when the connection closes (the socket is
|
|
32
|
+
* already closed by the time this runs)
|
|
33
|
+
* onError(err) — socket errors and outgoing-packet send errors
|
|
34
|
+
*
|
|
35
|
+
* Returns the UDP socket synchronously. The QUICConnection is created after the
|
|
36
|
+
* socket binds, so it is delivered via onConnect rather than returned here.
|
|
37
|
+
*/
|
|
38
|
+
function createQuicClientSocket(opts) {
|
|
39
|
+
var isIPv6 = opts.remoteIp.indexOf(':') >= 0;
|
|
40
|
+
var udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
|
|
41
|
+
var quic = null;
|
|
42
|
+
|
|
43
|
+
udpSocket.on('message', function (msg, rinfo) {
|
|
44
|
+
if (quic) quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
udpSocket.on('error', function (err) {
|
|
48
|
+
if (opts.onError) opts.onError(err);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
udpSocket.bind(0, function () {
|
|
52
|
+
quic = new QUICConnection({ isServer: false, hostname: opts.hostname });
|
|
53
|
+
|
|
54
|
+
quic.on('packet', function (data) {
|
|
55
|
+
udpSocket.send(data, opts.remotePort, opts.remoteIp, function (err) {
|
|
56
|
+
if (err && opts.onError) opts.onError(err);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
quic.on('connect', function () {
|
|
61
|
+
if (opts.onConnect) opts.onConnect(quic, udpSocket);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
quic.on('close', function () {
|
|
65
|
+
try { udpSocket.close(); } catch (e) {}
|
|
66
|
+
if (opts.onClose) opts.onClose();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
quic.connect();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return udpSocket;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
export { createQuicClientSocket };
|
package/src/server.js
CHANGED
|
@@ -19,25 +19,9 @@ import nodeHttps from 'node:https';
|
|
|
19
19
|
import nodeTls from 'node:tls';
|
|
20
20
|
|
|
21
21
|
import { createServer as createH3Server } from './h3_server.js';
|
|
22
|
-
import { createSecureContext as _lemonCreateSecureContext } from './tls_bridge.js';
|
|
23
22
|
import { IncomingMessage, ServerResponse, _makeSocketStub } from './streams.js';
|
|
24
23
|
|
|
25
24
|
|
|
26
|
-
/**
|
|
27
|
-
* Wrapped createSecureContext — stores raw key/cert on the context
|
|
28
|
-
* so server.js can reuse them for TCP (node:tls).
|
|
29
|
-
*/
|
|
30
|
-
function createSecureContext(opts) {
|
|
31
|
-
var ctx = _lemonCreateSecureContext(opts);
|
|
32
|
-
if (opts) {
|
|
33
|
-
ctx._rawKey = opts.key || null;
|
|
34
|
-
ctx._rawCert = opts.cert || null;
|
|
35
|
-
ctx._rawCa = opts.ca || null;
|
|
36
|
-
}
|
|
37
|
-
return ctx;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
25
|
// ============================================================
|
|
42
26
|
// createServer(options, handler)
|
|
43
27
|
//
|
|
@@ -89,9 +73,16 @@ function createServer(options, handler) {
|
|
|
89
73
|
cert: tlsCert,
|
|
90
74
|
ca: tlsCa,
|
|
91
75
|
SNICallback: options.SNICallback || null,
|
|
92
|
-
maxConnections: options.maxConnections || 10000
|
|
76
|
+
maxConnections: options.maxConnections || 10000,
|
|
77
|
+
// External UDP sockets (shared UDP port scenario). When provided,
|
|
78
|
+
// the UDP layer is owned externally — caller feeds packets via
|
|
79
|
+
// server.handlePacket(msg, rinfo). See RFC 9443.
|
|
80
|
+
socket: options.socket || null,
|
|
81
|
+
socket6: options.socket6 || null
|
|
93
82
|
};
|
|
94
83
|
|
|
84
|
+
var isExternalUdp = !!(options.socket || options.socket6);
|
|
85
|
+
|
|
95
86
|
var h3srv = createH3Server(h3Options, function (plainReq, plainRes) {
|
|
96
87
|
var socketStub = _makeSocketStub();
|
|
97
88
|
socketStub.remoteAddress = plainReq.remoteAddress || null;
|
|
@@ -111,10 +102,6 @@ function createServer(options, handler) {
|
|
|
111
102
|
plainReq.on('data', function (chunk) { req._pushData(chunk); });
|
|
112
103
|
plainReq.on('end', function () { req._endData(); });
|
|
113
104
|
|
|
114
|
-
// Store wrapper references for WebTransport event forwarding
|
|
115
|
-
plainReq._wrapper = req;
|
|
116
|
-
plainRes._wrapper = res;
|
|
117
|
-
|
|
118
105
|
// ---- Wrap plain res into ServerResponse (Writable) ----
|
|
119
106
|
// ServerResponse._write → plainRes.write
|
|
120
107
|
// ServerResponse._flushHeaders → plainRes.writeHead
|
|
@@ -125,6 +112,12 @@ function createServer(options, handler) {
|
|
|
125
112
|
});
|
|
126
113
|
res.req = req;
|
|
127
114
|
|
|
115
|
+
// Store wrapper references for WebTransport event forwarding.
|
|
116
|
+
// MUST come after `res` is created (was previously set while res was
|
|
117
|
+
// still undefined due to var hoisting).
|
|
118
|
+
plainReq._wrapper = req;
|
|
119
|
+
plainRes._wrapper = res;
|
|
120
|
+
|
|
128
121
|
// Override internal methods to delegate to the plain H3 res.
|
|
129
122
|
// Key insight: we buffer one chunk behind so that _final can
|
|
130
123
|
// send the last data + FIN together via plainRes.end(chunk),
|
|
@@ -285,7 +278,9 @@ function createServer(options, handler) {
|
|
|
285
278
|
}
|
|
286
279
|
}
|
|
287
280
|
|
|
288
|
-
// H3 (
|
|
281
|
+
// H3 — always. In external mode h3srv.listen() wires up the external
|
|
282
|
+
// sockets' message handler and starts the sweep timer, but does not
|
|
283
|
+
// bind anything new.
|
|
289
284
|
h3srv.listen(port, host, done);
|
|
290
285
|
|
|
291
286
|
// TCP — if needed
|
|
@@ -336,6 +331,19 @@ function createServer(options, handler) {
|
|
|
336
331
|
tcpServer.setTimeout(msecs, callback);
|
|
337
332
|
}
|
|
338
333
|
return server;
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
/** Feed an incoming UDP packet from a shared/external socket.
|
|
337
|
+
* rinfo must be in Node's dgram format: { address, port, family, size }.
|
|
338
|
+
* Only relevant when the server was created with options.socket/socket6. */
|
|
339
|
+
handlePacket: function (msg, rinfo) {
|
|
340
|
+
return h3srv.handlePacket(msg, rinfo);
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
/** Returns true if the 5-tuple matches an active QUIC connection.
|
|
344
|
+
* Used by demuxers for routing decisions on shared UDP ports. */
|
|
345
|
+
hasConnection: function (rinfo) {
|
|
346
|
+
return h3srv.hasConnection(rinfo);
|
|
339
347
|
}
|
|
340
348
|
};
|
|
341
349
|
|
package/src/tls_bridge.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { TLSSession, createSecureContext as _lemonCreateSecureContext } from 'lemon-tls';
|
|
16
16
|
import nodeTls from 'node:tls';
|
|
17
|
-
import { Emitter } from './utils.js';
|
|
18
|
-
import { build_transport_params } from './transport.js';
|
|
17
|
+
import { DEBUG, Emitter } from './utils.js';
|
|
18
|
+
import { build_transport_params, parse_transport_params } from './transport.js';
|
|
19
19
|
import {
|
|
20
20
|
TLS_CIPHER_SUITES
|
|
21
21
|
} from './crypto.js';
|
|
@@ -52,6 +52,11 @@ function TLSBridge(options) {
|
|
|
52
52
|
var originalDcid = options.originalDcid || new Uint8Array(0);
|
|
53
53
|
var localCid = options.localCid || new Uint8Array(0); // client's own SCID
|
|
54
54
|
var hostname = options.hostname || null;
|
|
55
|
+
// ALPN protocol(s) to advertise. Default ['h3']; normalize string → array.
|
|
56
|
+
var supportedAlpns = (function () {
|
|
57
|
+
var a = options.alpn || ['h3'];
|
|
58
|
+
return Array.isArray(a) ? a : [a];
|
|
59
|
+
})();
|
|
55
60
|
|
|
56
61
|
function feedMessage(data) {
|
|
57
62
|
if (!session) {
|
|
@@ -71,11 +76,27 @@ function TLSBridge(options) {
|
|
|
71
76
|
|
|
72
77
|
session.set_context({
|
|
73
78
|
local_supported_versions: [0x0304],
|
|
74
|
-
local_supported_alpns:
|
|
79
|
+
local_supported_alpns: supportedAlpns,
|
|
75
80
|
local_supported_groups: [29, 23, 24],
|
|
76
81
|
local_supported_cipher_suites: [0x1301, 0x1302],
|
|
77
82
|
local_extensions: [{ type: 0x39, data: quicParams }],
|
|
78
|
-
|
|
83
|
+
// Signature schemes offered to the peer (client: in the ClientHello;
|
|
84
|
+
// server: in CertificateRequest). Full modern list, ordered PSS/ECDSA/
|
|
85
|
+
// EdDSA first, legacy pkcs1 last (TLS 1.2 compat only — TLS 1.3 never
|
|
86
|
+
// signs pkcs1). Two bugs lived in the old pkcs1-only list:
|
|
87
|
+
// 1. Cloudflare zones with ECDSA-only certs reject the ClientHello
|
|
88
|
+
// outright (alert 40 handshake_failure — seen live against
|
|
89
|
+
// speed.cloudflare.com; claude.ai worked only because that zone
|
|
90
|
+
// also carries an RSA cert).
|
|
91
|
+
// 2. lemon-tls hardcodes THIS full list in the HRR CH2, and RFC 8446
|
|
92
|
+
// §4.1.2 requires CH2 to match CH1 exactly — offering a different
|
|
93
|
+
// list in CH1 made every HRR flow non-compliant.
|
|
94
|
+
local_supported_signature_algorithms: [
|
|
95
|
+
0x0804, 0x0805, 0x0806, // rsa_pss_rsae_sha256/384/512
|
|
96
|
+
0x0403, 0x0503, 0x0603, // ecdsa_secp256r1/384r1/521r1
|
|
97
|
+
0x0807, 0x0808, // ed25519, ed448
|
|
98
|
+
0x0401, 0x0501, 0x0601 // rsa_pkcs1 (TLS 1.2 legacy)
|
|
99
|
+
],
|
|
79
100
|
});
|
|
80
101
|
helloHandled = true; // ClientHello will be generated from this set_context
|
|
81
102
|
}
|
|
@@ -85,10 +106,35 @@ function TLSBridge(options) {
|
|
|
85
106
|
|
|
86
107
|
function setupSessionEvents() {
|
|
87
108
|
|
|
109
|
+
// Peer's QUIC transport parameters ride in the TLS quic_transport_parameters
|
|
110
|
+
// extension (type 0x39): client's in the ClientHello, server's in the
|
|
111
|
+
// EncryptedExtensions. lemon-tls surfaces every received handshake message via
|
|
112
|
+
// 'handshakeMessage' with the parsed extensions, so we extract the 0x39 data,
|
|
113
|
+
// parse it, and hand the params to the QUIC layer (which seeds remote_max_data,
|
|
114
|
+
// max_ack_delay, ack_delay_exponent, etc. — previously hardcoded defaults).
|
|
115
|
+
var peerParamsEmitted = false;
|
|
116
|
+
session.on('handshakeMessage', function (type, data, message) {
|
|
117
|
+
if (peerParamsEmitted || !message || !Array.isArray(message.extensions)) return;
|
|
118
|
+
for (var i = 0; i < message.extensions.length; i++) {
|
|
119
|
+
var e = message.extensions[i];
|
|
120
|
+
if (e && (e.type === 0x39 || e.type === 57) && e.data) {
|
|
121
|
+
try {
|
|
122
|
+
var parsed = parse_transport_params(e.data, 0);
|
|
123
|
+
peerParamsEmitted = true;
|
|
124
|
+
if (DEBUG) console.log('[tls] peer transport params parsed (' + e.data.length + 'B)');
|
|
125
|
+
ev.emit('peerTransportParams', parsed);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (DEBUG) console.log('[tls] peer transport params parse failed: ' + err.message);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
88
134
|
session.on('hello', function () {
|
|
89
135
|
if (helloHandled) return; // prevent double-fire
|
|
90
136
|
helloHandled = true;
|
|
91
|
-
console.log('[tls] hello — configuring TLS');
|
|
137
|
+
if (DEBUG) console.log('[tls] hello — configuring TLS');
|
|
92
138
|
|
|
93
139
|
// Transport params — shared defaults + role-specific fields
|
|
94
140
|
var tp = defaultTransportParams();
|
|
@@ -107,13 +153,29 @@ function TLSBridge(options) {
|
|
|
107
153
|
|
|
108
154
|
var tlsContext = {
|
|
109
155
|
local_supported_versions: [0x0304],
|
|
110
|
-
local_supported_alpns:
|
|
156
|
+
local_supported_alpns: supportedAlpns,
|
|
111
157
|
local_supported_groups: [29, 23, 24],
|
|
112
158
|
local_supported_cipher_suites: [0x1301, 0x1302, 0xc02f, 0xc030, 0xcca8],
|
|
113
159
|
local_extensions: [
|
|
114
160
|
{ type: 0x39, data: quicParams }
|
|
115
161
|
],
|
|
116
|
-
|
|
162
|
+
// Signature schemes offered to the peer (client: in the ClientHello;
|
|
163
|
+
// server: in CertificateRequest). Full modern list, ordered PSS/ECDSA/
|
|
164
|
+
// EdDSA first, legacy pkcs1 last (TLS 1.2 compat only — TLS 1.3 never
|
|
165
|
+
// signs pkcs1). Two bugs lived in the old pkcs1-only list:
|
|
166
|
+
// 1. Cloudflare zones with ECDSA-only certs reject the ClientHello
|
|
167
|
+
// outright (alert 40 handshake_failure — seen live against
|
|
168
|
+
// speed.cloudflare.com; claude.ai worked only because that zone
|
|
169
|
+
// also carries an RSA cert).
|
|
170
|
+
// 2. lemon-tls hardcodes THIS full list in the HRR CH2, and RFC 8446
|
|
171
|
+
// §4.1.2 requires CH2 to match CH1 exactly — offering a different
|
|
172
|
+
// list in CH1 made every HRR flow non-compliant.
|
|
173
|
+
local_supported_signature_algorithms: [
|
|
174
|
+
0x0804, 0x0805, 0x0806, // rsa_pss_rsae_sha256/384/512
|
|
175
|
+
0x0403, 0x0503, 0x0603, // ecdsa_secp256r1/384r1/521r1
|
|
176
|
+
0x0807, 0x0808, // ed25519, ed448
|
|
177
|
+
0x0401, 0x0501, 0x0601 // rsa_pkcs1 (TLS 1.2 legacy)
|
|
178
|
+
],
|
|
117
179
|
};
|
|
118
180
|
|
|
119
181
|
session.set_context(tlsContext);
|
|
@@ -125,7 +187,7 @@ function TLSBridge(options) {
|
|
|
125
187
|
else if (epoch === 1) quicEpoch = 'handshake';
|
|
126
188
|
else quicEpoch = 'app';
|
|
127
189
|
|
|
128
|
-
console.log('[tls] outgoing: epoch=' + quicEpoch + ' type=' + type + ' len=' + data.length);
|
|
190
|
+
if (DEBUG) console.log('[tls] outgoing: epoch=' + quicEpoch + ' type=' + type + ' len=' + data.length);
|
|
129
191
|
ev.emit('send', quicEpoch, data);
|
|
130
192
|
});
|
|
131
193
|
|
|
@@ -135,7 +197,7 @@ function TLSBridge(options) {
|
|
|
135
197
|
handshakeSecretsEmitted = true;
|
|
136
198
|
var cipher = session.getCipher();
|
|
137
199
|
var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
|
|
138
|
-
console.log('[tls] handshake secrets ready');
|
|
200
|
+
if (DEBUG) console.log('[tls] handshake secrets ready');
|
|
139
201
|
ev.emit('handshakeSecrets', { local: localSecret, remote: remoteSecret, cipher: cipher, hash: hashName });
|
|
140
202
|
});
|
|
141
203
|
|
|
@@ -144,12 +206,12 @@ function TLSBridge(options) {
|
|
|
144
206
|
appSecretsEmitted = true;
|
|
145
207
|
var cipher = session.getCipher();
|
|
146
208
|
var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
|
|
147
|
-
console.log('[tls] app secrets ready');
|
|
209
|
+
if (DEBUG) console.log('[tls] app secrets ready');
|
|
148
210
|
ev.emit('appSecrets', { local: localSecret, remote: remoteSecret, cipher: cipher, hash: hashName });
|
|
149
211
|
});
|
|
150
212
|
|
|
151
213
|
session.on('secureConnect', function () {
|
|
152
|
-
console.log('[tls] secureConnect');
|
|
214
|
+
if (DEBUG) console.log('[tls] secureConnect');
|
|
153
215
|
ev.emit('secureConnect');
|
|
154
216
|
});
|
|
155
217
|
|
|
@@ -208,4 +270,4 @@ function createSecureContext(options) {
|
|
|
208
270
|
return ctx;
|
|
209
271
|
}
|
|
210
272
|
|
|
211
|
-
export { TLSBridge, createSecureContext };
|
|
273
|
+
export { TLSBridge, createSecureContext };
|