quico 0.7.0 → 0.9.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 +247 -33
- package/index.d.ts +1034 -77
- package/index.js +7 -3
- package/package.json +9 -5
- package/src/client.js +1 -0
- package/src/connect_udp.js +5 -2
- package/src/connect_udp_client.js +10 -0
- package/src/crypto.js +6 -0
- package/src/h1_server.js +51 -9
- package/src/h2_server.js +60 -11
- package/src/h3.js +115 -70
- package/src/h3_client.js +25 -5
- package/src/h3_server.js +104 -6
- package/src/hpack_primitives.js +64 -25
- package/src/http_common.js +7 -0
- package/src/qpack.js +11 -2
- package/src/quic_connection.js +379 -63
- package/src/quic_server.js +213 -72
- package/src/quic_socket.js +5 -5
- package/src/server.js +277 -24
- package/src/streams.js +183 -10
- package/src/tcp_listener.js +11 -0
- package/src/tls_bridge.js +62 -38
- package/src/transport.js +6 -2
- package/src/utils.js +36 -0
- package/src/webtransport.js +432 -57
- package/src/ws.js +120 -0
- package/src/ws_client.js +68 -88
- package/src/ws_server.js +35 -53
- package/src/ws_stream.js +250 -0
- package/src/wt_stream.js +533 -76
package/src/streams.js
CHANGED
|
@@ -59,17 +59,38 @@ class IncomingMessage extends Readable {
|
|
|
59
59
|
// QUIC has no TCP socket — stub so libraries don't crash
|
|
60
60
|
this.socket = options.socket || _makeSocketStub();
|
|
61
61
|
this.connection = this.socket;
|
|
62
|
+
this.client = this.socket; // node:http's older alias
|
|
63
|
+
// node:http's: true on a request handed to 'upgrade' (an HTTP/1.1
|
|
64
|
+
// Upgrade, an extended CONNECT over HTTP/2 and HTTP/3).
|
|
65
|
+
this.upgrade = false;
|
|
62
66
|
|
|
63
67
|
// H3 stream id (internal)
|
|
64
68
|
this._streamId = options.streamId != null ? options.streamId : null;
|
|
65
69
|
this._timeoutTimer = null;
|
|
66
70
|
|
|
67
|
-
// Build rawHeaders
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// Build rawHeaders: one name and value per field line, as node:http has
|
|
72
|
+
// them — the field lines as they came when the decoder kept them
|
|
73
|
+
// (validateFieldSection's _rawHeaders); otherwise from the map, a list
|
|
74
|
+
// (Set-Cookie) one entry per value. String(list) made two cookies one
|
|
75
|
+
// "a=1,b=2".
|
|
76
|
+
var rawList = this.headers._rawHeaders;
|
|
77
|
+
if (Array.isArray(rawList)) {
|
|
78
|
+
for (var ri = 0; ri < rawList.length; ri++) this.rawHeaders.push(rawList[ri]);
|
|
79
|
+
} else {
|
|
80
|
+
for (var name in this.headers) {
|
|
81
|
+
var hv = this.headers[name];
|
|
82
|
+
if (Array.isArray(hv)) { for (var hi = 0; hi < hv.length; hi++) this.rawHeaders.push(name, String(hv[hi])); }
|
|
83
|
+
else this.rawHeaders.push(name, String(hv));
|
|
84
|
+
}
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
87
|
|
|
88
|
+
// node:http's headersDistinct / trailersDistinct: each name (lower case)
|
|
89
|
+
// with every value it came with, as an array, nothing joined — from the
|
|
90
|
+
// field lines as they arrived (rawHeaders, rawTrailers).
|
|
91
|
+
get headersDistinct() { return distinctFields(this.rawHeaders); }
|
|
92
|
+
get trailersDistinct() { return distinctFields(this.rawTrailers); }
|
|
93
|
+
|
|
73
94
|
// Readable requires _read — data is pushed externally, so no-op.
|
|
74
95
|
_read(_size) { /* no-op */ }
|
|
75
96
|
|
|
@@ -184,6 +205,11 @@ class ServerResponse extends Writable {
|
|
|
184
205
|
this.finished = false;
|
|
185
206
|
this._headers = {};
|
|
186
207
|
this._headerNames = {}; // lowercase → original-case
|
|
208
|
+
// node:http's: a Date header unless false (RFC 9110 §6.6.1); and, when
|
|
209
|
+
// true, a body that does not match its Content-Length throws.
|
|
210
|
+
this.sendDate = true;
|
|
211
|
+
this.strictContentLength = false;
|
|
212
|
+
this._bodyBytes = 0;
|
|
187
213
|
|
|
188
214
|
// H3 references (set by server when creating the response)
|
|
189
215
|
this._streamId = options.streamId != null ? options.streamId : null;
|
|
@@ -210,6 +236,9 @@ class ServerResponse extends Writable {
|
|
|
210
236
|
this.getHeaderNames = this.getHeaderNames.bind(this);
|
|
211
237
|
this.hasHeader = this.hasHeader.bind(this);
|
|
212
238
|
this.removeHeader = this.removeHeader.bind(this);
|
|
239
|
+
this.appendHeader = this.appendHeader.bind(this);
|
|
240
|
+
this.setHeaders = this.setHeaders.bind(this);
|
|
241
|
+
this.getRawHeaderNames = this.getRawHeaderNames.bind(this);
|
|
213
242
|
this.writeHead = this.writeHead.bind(this);
|
|
214
243
|
this.flushHeaders = this.flushHeaders.bind(this);
|
|
215
244
|
this._flushHeaders = this._flushHeaders.bind(this);
|
|
@@ -270,8 +299,10 @@ class ServerResponse extends Writable {
|
|
|
270
299
|
return this._headers[name.toLowerCase()];
|
|
271
300
|
}
|
|
272
301
|
|
|
302
|
+
// An object with no prototype, as node:http's: a header named
|
|
303
|
+
// "constructor" or "__proto__" is a header, not a property it inherits.
|
|
273
304
|
getHeaders() {
|
|
274
|
-
var out =
|
|
305
|
+
var out = Object.create(null);
|
|
275
306
|
for (var k in this._headers) out[k] = this._headers[k];
|
|
276
307
|
return out;
|
|
277
308
|
}
|
|
@@ -280,6 +311,38 @@ class ServerResponse extends Writable {
|
|
|
280
311
|
return Object.keys(this._headers);
|
|
281
312
|
}
|
|
282
313
|
|
|
314
|
+
// node:http's: the names as they were set, in their case.
|
|
315
|
+
getRawHeaderNames() {
|
|
316
|
+
var out = [];
|
|
317
|
+
for (var k in this._headers) out.push(this._headerNames[k] || k);
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// node:http's: a value added to a header's (both kept), not in its place.
|
|
322
|
+
appendHeader(name, value) {
|
|
323
|
+
if (this.headersSent) throw headersSentError('append', this.req && this.req.httpVersion);
|
|
324
|
+
return this._appendHeader(name, value);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// node:http's: several at once, from a Headers or a Map — each replacing
|
|
328
|
+
// that name's value (Set-Cookie: every one of a Headers' cookies).
|
|
329
|
+
setHeaders(headers) {
|
|
330
|
+
if (this.headersSent) throw headersSentError('set', this.req && this.req.httpVersion);
|
|
331
|
+
var isHeaders = typeof Headers !== 'undefined' && headers instanceof Headers;
|
|
332
|
+
if (!isHeaders && !(headers instanceof Map)) {
|
|
333
|
+
var e = new TypeError('The "headers" argument must be an instance of Headers or Map');
|
|
334
|
+
e.code = 'ERR_INVALID_ARG_TYPE';
|
|
335
|
+
throw e;
|
|
336
|
+
}
|
|
337
|
+
var self = this, cookies = null;
|
|
338
|
+
headers.forEach(function (value, name) {
|
|
339
|
+
if (isHeaders && String(name).toLowerCase() === 'set-cookie') { cookies = headers.getSetCookie(); return; }
|
|
340
|
+
self.setHeader(name, value);
|
|
341
|
+
});
|
|
342
|
+
if (cookies) this.setHeader('set-cookie', cookies);
|
|
343
|
+
return this;
|
|
344
|
+
}
|
|
345
|
+
|
|
283
346
|
hasHeader(name) {
|
|
284
347
|
return name.toLowerCase() in this._headers;
|
|
285
348
|
}
|
|
@@ -292,6 +355,16 @@ class ServerResponse extends Writable {
|
|
|
292
355
|
return this;
|
|
293
356
|
}
|
|
294
357
|
|
|
358
|
+
// A value added to what a name has (kept as a list), not in its place.
|
|
359
|
+
_appendHeader(name, value) {
|
|
360
|
+
var lower = String(name).toLowerCase();
|
|
361
|
+
if (!(lower in this._headers)) return this.setHeader(name, value);
|
|
362
|
+
validateHeaderName(name);
|
|
363
|
+
validateHeaderValue(name, value);
|
|
364
|
+
this._headers[lower] = [].concat(this._headers[lower], value);
|
|
365
|
+
return this;
|
|
366
|
+
}
|
|
367
|
+
|
|
295
368
|
/**
|
|
296
369
|
* node:http's res.addTrailers(headers): fields sent after the body — an
|
|
297
370
|
* object, or an array of [name, value] pairs. Over HTTP/1.1 they go in the
|
|
@@ -325,7 +398,22 @@ class ServerResponse extends Writable {
|
|
|
325
398
|
this.statusCode = statusCode;
|
|
326
399
|
if (statusMessage !== undefined) this.statusMessage = String(statusMessage);
|
|
327
400
|
|
|
328
|
-
|
|
401
|
+
// node:http's three forms: an object; a flat array [name, value, name,
|
|
402
|
+
// value]; an array of [name, value] pairs. The arrays may repeat a name
|
|
403
|
+
// and every value is kept (two Set-Cookie lines). An array went through
|
|
404
|
+
// for…in: headers named "0", "1", … — over HTTP/3 the response failed.
|
|
405
|
+
if (Array.isArray(headers)) {
|
|
406
|
+
var tuples = headers.length > 0 && Array.isArray(headers[0]);
|
|
407
|
+
if (!tuples && headers.length % 2 !== 0) {
|
|
408
|
+
var bad = new TypeError('The argument \'headers\' is invalid: an array of names and values must have an even length');
|
|
409
|
+
bad.code = 'ERR_INVALID_ARG_VALUE';
|
|
410
|
+
throw bad;
|
|
411
|
+
}
|
|
412
|
+
for (var i = 0; i < headers.length; i += tuples ? 1 : 2) {
|
|
413
|
+
var hn = tuples ? headers[i][0] : headers[i], hv = tuples ? headers[i][1] : headers[i + 1];
|
|
414
|
+
if (hn) this._appendHeader(hn, hv);
|
|
415
|
+
}
|
|
416
|
+
} else if (typeof headers === 'object' && headers !== null) {
|
|
329
417
|
for (var name in headers) this.setHeader(name, headers[name]);
|
|
330
418
|
}
|
|
331
419
|
|
|
@@ -372,12 +460,38 @@ class ServerResponse extends Writable {
|
|
|
372
460
|
write(chunk, encoding, callback) {
|
|
373
461
|
if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
|
|
374
462
|
this._wrote = true;
|
|
463
|
+
this._countBody(chunk, encoding, false);
|
|
375
464
|
return super.write(chunk, encoding, callback);
|
|
376
465
|
}
|
|
377
466
|
|
|
467
|
+
// strictContentLength (node:http's): the body may not go past its
|
|
468
|
+
// Content-Length, nor end short of it — ERR_HTTP_CONTENT_LENGTH_MISMATCH,
|
|
469
|
+
// thrown from the write() or end() that shows it.
|
|
470
|
+
// Counted only once checked: a write that threw sent nothing.
|
|
471
|
+
_countBody(chunk, encoding, ending) {
|
|
472
|
+
var total = this._bodyBytes;
|
|
473
|
+
if (chunk != null && !this.writableEnded) {
|
|
474
|
+
total += typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : (chunk.byteLength || 0);
|
|
475
|
+
}
|
|
476
|
+
var declared = this.strictContentLength ? this.getHeader('content-length') : undefined;
|
|
477
|
+
if (declared !== undefined && !Array.isArray(declared) && !(this.req && this.req.method === 'HEAD') && Number(declared) >= 0) {
|
|
478
|
+
declared = Number(declared);
|
|
479
|
+
if (total > declared || (ending && total !== declared)) {
|
|
480
|
+
var e = new Error('Response body\'s content-length of ' + total + ' byte(s) does not match the content-length of ' + declared + ' byte(s) set in header');
|
|
481
|
+
e.code = 'ERR_HTTP_CONTENT_LENGTH_MISMATCH';
|
|
482
|
+
throw e;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
this._bodyBytes = total;
|
|
486
|
+
}
|
|
487
|
+
|
|
378
488
|
end(data, encoding, callback) {
|
|
379
489
|
if (typeof data === 'function') { callback = data; data = undefined; encoding = undefined; }
|
|
380
490
|
if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
|
|
491
|
+
if (!this.writableEnded) {
|
|
492
|
+
if (data != null) this._countBody(data, encoding, true);
|
|
493
|
+
else this._countBody(null, null, true);
|
|
494
|
+
}
|
|
381
495
|
// The whole body, when nothing was written before it and the head has
|
|
382
496
|
// not gone: its length, for HTTP/1.1 to send as Content-Length rather
|
|
383
497
|
// than chunk it (node:http's end() does the same). HTTP/2 and HTTP/3
|
|
@@ -388,8 +502,22 @@ class ServerResponse extends Writable {
|
|
|
388
502
|
: data.byteLength;
|
|
389
503
|
if (typeof len === 'number') this._endLength = len; // else write() rejects it
|
|
390
504
|
}
|
|
391
|
-
if (data != null) this.
|
|
392
|
-
|
|
505
|
+
if (data != null) this._writeCounted(data, encoding);
|
|
506
|
+
// The callback runs at 'finish', after the listeners already on it — as
|
|
507
|
+
// node:http's end(cb), a once('finish'). Given to Writable's end() it
|
|
508
|
+
// ran before them.
|
|
509
|
+
if (typeof callback === 'function') {
|
|
510
|
+
if (this.writableFinished) process.nextTick(callback);
|
|
511
|
+
else this.once('finish', callback);
|
|
512
|
+
}
|
|
513
|
+
super.end();
|
|
514
|
+
return this;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The last write, its bytes already counted by end().
|
|
518
|
+
_writeCounted(data, encoding) {
|
|
519
|
+
this._wrote = true;
|
|
520
|
+
return super.write(data, encoding);
|
|
393
521
|
}
|
|
394
522
|
|
|
395
523
|
writeContinue() { /* HTTP/3 doesn't use 100-continue — stub */ }
|
|
@@ -405,6 +533,12 @@ class ServerResponse extends Writable {
|
|
|
405
533
|
if (typeof callback === 'function') process.nextTick(callback);
|
|
406
534
|
}
|
|
407
535
|
|
|
536
|
+
/**
|
|
537
|
+
* 102 Processing — node:http's response.writeProcessing(). Replaced by the
|
|
538
|
+
* layer that made the response, as writeEarlyHints is; here, nothing.
|
|
539
|
+
*/
|
|
540
|
+
writeProcessing() {}
|
|
541
|
+
|
|
408
542
|
/**
|
|
409
543
|
* The response's priority, { urgency, incremental } (RFC 9218). HTTP/2 and
|
|
410
544
|
* HTTP/3 replace this with the real thing (h2_server, h3_server); over
|
|
@@ -593,6 +727,21 @@ class ClientRequest extends Writable {
|
|
|
593
727
|
if (Buffer.isBuffer(chunk)) {
|
|
594
728
|
chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
595
729
|
}
|
|
730
|
+
if (this._sink && this._wholeBody) {
|
|
731
|
+
// end(data) with nothing written before it: the body whole, handed to
|
|
732
|
+
// the transport as its end — sent with a content-length, as node:http
|
|
733
|
+
// sends end(data). Written, then ended, a transport already able to
|
|
734
|
+
// send had begun streaming on the write: no length, and a server that
|
|
735
|
+
// goes by the headers (Express's body-parser) saw no body. It happened
|
|
736
|
+
// whenever the connection was ready at once — a pooled one.
|
|
737
|
+
this._wholeBody = false;
|
|
738
|
+
this._sinkEnded = true;
|
|
739
|
+
this._remember(chunk);
|
|
740
|
+
this._sink.end(chunk, this._trailers);
|
|
741
|
+
callback();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
this._wholeBody = false;
|
|
596
745
|
if (this._sink) { this._toSink(chunk, callback); return; }
|
|
597
746
|
// No transport yet (still connecting): held — and the writer told to wait
|
|
598
747
|
// past the high-water mark, as a socket not yet connected would.
|
|
@@ -604,7 +753,7 @@ class ClientRequest extends Writable {
|
|
|
604
753
|
|
|
605
754
|
_final(callback) {
|
|
606
755
|
this.finished = true;
|
|
607
|
-
if (this._sink) this._sink.end(null, this._trailers);
|
|
756
|
+
if (this._sink && !this._sinkEnded) { this._sinkEnded = true; this._sink.end(null, this._trailers); }
|
|
608
757
|
callback();
|
|
609
758
|
}
|
|
610
759
|
|
|
@@ -647,12 +796,21 @@ class ClientRequest extends Writable {
|
|
|
647
796
|
});
|
|
648
797
|
var held = this._bodyChunks, more = true;
|
|
649
798
|
this._bodyChunks = []; this._bodyBuffered = 0;
|
|
799
|
+
this._sinkEnded = false;
|
|
800
|
+
if (this.finished) {
|
|
801
|
+
// Ended before a transport took it: the body is known whole — one
|
|
802
|
+
// end(), so it goes with its length rather than streamed.
|
|
803
|
+
for (var j = 0; j < held.length; j++) this._remember(held[j]);
|
|
804
|
+
var whole = held.length === 0 ? null : held.length === 1 ? held[0] : Buffer.concat(held);
|
|
805
|
+
this._sinkEnded = true;
|
|
806
|
+
sink.end(whole, this._trailers);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
650
809
|
for (var i = 0; i < held.length; i++) { this._remember(held[i]); more = sink.write(held[i]); }
|
|
651
810
|
if (this._pendingWriteCb) {
|
|
652
811
|
var cb = this._pendingWriteCb; this._pendingWriteCb = null;
|
|
653
812
|
if (more) cb(); else this._sinkWaitCb = cb;
|
|
654
813
|
}
|
|
655
|
-
if (this.finished) sink.end(null, this._trailers);
|
|
656
814
|
}
|
|
657
815
|
|
|
658
816
|
// The transport failed before the server processed the request: the body
|
|
@@ -684,13 +842,18 @@ class ClientRequest extends Writable {
|
|
|
684
842
|
|
|
685
843
|
write(chunk, encoding, callback) {
|
|
686
844
|
if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
|
|
845
|
+
this._wroteBody = true;
|
|
687
846
|
return super.write(chunk, encoding, callback);
|
|
688
847
|
}
|
|
689
848
|
|
|
690
849
|
end(data, encoding, callback) {
|
|
691
850
|
if (typeof data === 'function') { callback = data; data = undefined; encoding = undefined; }
|
|
692
851
|
if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
|
|
693
|
-
if (data != null)
|
|
852
|
+
if (data != null) {
|
|
853
|
+
// Nothing written before: this chunk is the body whole (_write).
|
|
854
|
+
this._wholeBody = !this._wroteBody && !this.writableEnded;
|
|
855
|
+
this.write(data, encoding);
|
|
856
|
+
}
|
|
694
857
|
return super.end(callback);
|
|
695
858
|
}
|
|
696
859
|
|
|
@@ -778,6 +941,16 @@ function reparent(obj, Base) {
|
|
|
778
941
|
Object.setPrototypeOf(obj, top);
|
|
779
942
|
}
|
|
780
943
|
|
|
944
|
+
// rawHeaders' pairs as node:http's headersDistinct: name → [values].
|
|
945
|
+
function distinctFields(raw) {
|
|
946
|
+
var out = {};
|
|
947
|
+
for (var i = 0; raw && i + 1 < raw.length; i += 2) {
|
|
948
|
+
var n = String(raw[i]).toLowerCase();
|
|
949
|
+
(out[n] || (out[n] = [])).push(String(raw[i + 1]));
|
|
950
|
+
}
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
|
|
781
954
|
function _makeSocketStub() {
|
|
782
955
|
var sock = new Duplex({
|
|
783
956
|
read(_size) { /* no-op — no data from QUIC socket */ },
|
package/src/tcp_listener.js
CHANGED
|
@@ -67,6 +67,7 @@ function createTcpListener(options) {
|
|
|
67
67
|
var order = []; // registration order = ALPN preference order
|
|
68
68
|
var sockets = new Set();
|
|
69
69
|
var closing = false;
|
|
70
|
+
var refed = true;
|
|
70
71
|
|
|
71
72
|
var tls = options.tls || null;
|
|
72
73
|
var cachedContext = null;
|
|
@@ -134,6 +135,7 @@ function createTcpListener(options) {
|
|
|
134
135
|
if (stats) stats.protocol(protocol);
|
|
135
136
|
|
|
136
137
|
sockets.add(socket);
|
|
138
|
+
socket._quicoProtocol = protocol;
|
|
137
139
|
socket.on('close', function () { sockets.delete(socket); });
|
|
138
140
|
if (handlerOpts[protocol] && handlerOpts[protocol].paused) socket.pause();
|
|
139
141
|
handler(socket, protocol);
|
|
@@ -501,6 +503,7 @@ function createTcpListener(options) {
|
|
|
501
503
|
if (order.length === 0) throw new Error('no protocol handlers registered');
|
|
502
504
|
|
|
503
505
|
server = net.createServer({ allowHalfOpen: false }, handleConnection);
|
|
506
|
+
if (!refed) server.unref();
|
|
504
507
|
server.on('error', function (err) { ev.emit('error', err); });
|
|
505
508
|
server.listen(port, host, function () {
|
|
506
509
|
ev.emit('listening');
|
|
@@ -521,7 +524,12 @@ function createTcpListener(options) {
|
|
|
521
524
|
// — sockets handed in through handleConnection — there is no net.Server
|
|
522
525
|
// here to close, and returning early (as this did) left every one of
|
|
523
526
|
// those connections open after shutdown.
|
|
527
|
+
// A protocol that closes its own connections (registered with
|
|
528
|
+
// managesClose: HTTP/1.1 and HTTP/2 finish what is in flight first) is
|
|
529
|
+
// left to it.
|
|
524
530
|
for (var socket of sockets) {
|
|
531
|
+
var po = handlerOpts[socket._quicoProtocol];
|
|
532
|
+
if (po && po.managesClose) continue;
|
|
525
533
|
if (!socket._quicoBusy) { try { socket.end(); } catch (e) {} }
|
|
526
534
|
}
|
|
527
535
|
|
|
@@ -540,6 +548,9 @@ function createTcpListener(options) {
|
|
|
540
548
|
off: function (n, f) { ev.off(n, f); return listener; },
|
|
541
549
|
get protocols() { return order.slice(); },
|
|
542
550
|
get connections() { return sockets.size; },
|
|
551
|
+
// node:net's: whether the process may exit while this listens.
|
|
552
|
+
ref: function () { refed = true; if (server && server.ref) server.ref(); return listener; },
|
|
553
|
+
unref: function () { refed = false; if (server && server.unref) server.unref(); return listener; },
|
|
543
554
|
|
|
544
555
|
// The session ticket keys — tls.Server's names. A new key takes effect
|
|
545
556
|
// for the next connection; tickets sealed with the one before no longer
|
package/src/tls_bridge.js
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
createSecureContext as _lemonCreateSecureContext,
|
|
18
18
|
certificateObject
|
|
19
19
|
} from 'lemon-tls';
|
|
20
|
-
import { X509Certificate } from 'node:crypto';
|
|
21
20
|
import { DEBUG, Emitter } from './utils.js';
|
|
22
21
|
import { rootCertificates as lemonRoots, parseEcdhCurve as lemonParseEcdhCurve, DEFAULT_SERVER_GROUPS as lemonServerGroups,
|
|
23
22
|
DEFAULT_SIGNATURE_SCHEMES as lemonSigSchemes } from 'lemon-tls';
|
|
@@ -159,6 +158,16 @@ function TLSBridge(options) {
|
|
|
159
158
|
var handshakeSecretsEmitted = false;
|
|
160
159
|
var appSecretsEmitted = false;
|
|
161
160
|
var helloHandled = false;
|
|
161
|
+
// A handshake refused here, for what QUIC asks of TLS (RFC 9001): the
|
|
162
|
+
// alert closes the connection; nothing more is answered or sent.
|
|
163
|
+
var refused = false;
|
|
164
|
+
function refuse(alert, why) {
|
|
165
|
+
if (refused) return;
|
|
166
|
+
refused = true;
|
|
167
|
+
if (DEBUG) console.log('[tls] refused: ' + why);
|
|
168
|
+
ev.emit('alert', alert);
|
|
169
|
+
ev.emit('error', Object.assign(new Error(why), { code: 'ERR_QUIC_TLS' }));
|
|
170
|
+
}
|
|
162
171
|
// A client's: what it keeps with each session it saves — the server's
|
|
163
172
|
// transport parameters (tp), and the application's own (app: HTTP/3's
|
|
164
173
|
// SETTINGS) once it has them — for 0-RTT next time.
|
|
@@ -363,31 +372,44 @@ function TLSBridge(options) {
|
|
|
363
372
|
// 'handshakeMessage' with the parsed extensions, so we extract the 0x39 data,
|
|
364
373
|
// parse it, and hand the params to the QUIC layer (which seeds remote_max_data,
|
|
365
374
|
// max_ack_delay, ack_delay_exponent, etc. — previously hardcoded defaults).
|
|
375
|
+
//
|
|
376
|
+
// The extension is required (RFC 9001 §8.2): a ClientHello — every one, a
|
|
377
|
+
// second after a HelloRetryRequest included — or EncryptedExtensions
|
|
378
|
+
// without it is refused with missing_extension, before anything is
|
|
379
|
+
// answered. TLS itself has no reason to require it; QUIC does, so here.
|
|
366
380
|
var peerParamsEmitted = false;
|
|
367
381
|
session.on('handshakeMessage', function (type, data, message) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
382
|
+
var carrier = isServer ? 'client_hello' : 'encrypted_extensions';
|
|
383
|
+
if (type !== carrier || refused) return;
|
|
384
|
+
var ext = null;
|
|
385
|
+
var list = (message && Array.isArray(message.extensions)) ? message.extensions : [];
|
|
386
|
+
for (var i = 0; i < list.length; i++) {
|
|
387
|
+
var e = list[i];
|
|
388
|
+
if (e && (e.type === 0x39 || e.type === 57) && e.data) { ext = e; break; }
|
|
389
|
+
}
|
|
390
|
+
if (!ext) { refuse(109, 'no quic_transport_parameters in the ' + type); return; }
|
|
391
|
+
if (peerParamsEmitted) return;
|
|
392
|
+
var parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = parse_transport_params(ext.data, 0);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
// RFC 9000 §7.4: TRANSPORT_PARAMETER_ERROR, not a connection that
|
|
397
|
+
// goes on with the defaults.
|
|
398
|
+
refused = true;
|
|
399
|
+
ev.emit('transportParamsError', 'quic_transport_parameters: ' + err.message);
|
|
400
|
+
return;
|
|
386
401
|
}
|
|
402
|
+
peerParamsEmitted = true;
|
|
403
|
+
if (DEBUG) console.log('[tls] peer transport params parsed (' + ext.data.length + 'B)');
|
|
404
|
+
// A client keeps, with the sessions this connection saves, the
|
|
405
|
+
// server's parameters that 0-RTT must stay within (RFC 9000
|
|
406
|
+
// §7.4.1) — given back with the early secret next time.
|
|
407
|
+
if (!isServer) keepAppData('tp', rememberedParams(parsed));
|
|
408
|
+
ev.emit('peerTransportParams', parsed);
|
|
387
409
|
});
|
|
388
410
|
|
|
389
411
|
session.on('hello', function () {
|
|
390
|
-
if (helloHandled) return; // prevent double-fire
|
|
412
|
+
if (helloHandled || refused) return; // prevent double-fire
|
|
391
413
|
helloHandled = true;
|
|
392
414
|
if (DEBUG) console.log('[tls] hello — configuring TLS');
|
|
393
415
|
|
|
@@ -435,6 +457,15 @@ function TLSBridge(options) {
|
|
|
435
457
|
if (data && data.length >= 2 && data[0] === 2) ev.emit('alert', data[1]);
|
|
436
458
|
return;
|
|
437
459
|
}
|
|
460
|
+
// Refused above: nothing more of this handshake goes out.
|
|
461
|
+
if (refused) return;
|
|
462
|
+
// Never sent over QUIC (RFC 9001 §6, §8.3), whatever TLS would do over
|
|
463
|
+
// TCP: a KeyUpdate (QUIC has its own), an EndOfEarlyData. Should TLS
|
|
464
|
+
// make one, the connection ends — internal_error — rather than send it.
|
|
465
|
+
if (type === 'key_update' || type === 'end_of_early_data') {
|
|
466
|
+
refuse(80, 'TLS produced a ' + type + ', which QUIC never sends');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
438
469
|
var quicEpoch;
|
|
439
470
|
if (epoch === 0) quicEpoch = 'initial';
|
|
440
471
|
else if (epoch === 1) quicEpoch = 'handshake';
|
|
@@ -525,7 +556,6 @@ function TLSBridge(options) {
|
|
|
525
556
|
on: function (name, fn) { ev.on(name, fn); },
|
|
526
557
|
off: function (name, fn) { ev.off(name, fn); },
|
|
527
558
|
feedMessage: feedMessage,
|
|
528
|
-
requestKeyUpdate: function () { if (session) session.requestKeyUpdate(true); },
|
|
529
559
|
getHandshakeSecrets: function () {
|
|
530
560
|
if (!session) return null;
|
|
531
561
|
var hs = session.getHandshakeSecrets();
|
|
@@ -620,6 +650,10 @@ function TLSBridge(options) {
|
|
|
620
650
|
*/
|
|
621
651
|
getJA4: function (opts) { return session && session.getJA4 ? session.getJA4(quicOpts(opts)) : null; },
|
|
622
652
|
|
|
653
|
+
/** TLS keying material (RFC 8446 §7.5), as node:tls's exportKeyingMaterial(length, label, context) — or null before the handshake has the secret. */
|
|
654
|
+
exportKeyingMaterial: function (length, label, context) {
|
|
655
|
+
return session && session.exportKeyingMaterial ? session.exportKeyingMaterial(length, label, context) : null;
|
|
656
|
+
},
|
|
623
657
|
/** The peer's certificate chain, leaf first, as DER — or null. */
|
|
624
658
|
getPeerCertificateChain: function () {
|
|
625
659
|
var chain = session && session.getPeerCertificate ? session.getPeerCertificate() : null;
|
|
@@ -658,27 +692,17 @@ function TLSBridge(options) {
|
|
|
658
692
|
* fingerprint, fingerprint256, serialNumber, raw }, or {} when none. On a
|
|
659
693
|
* server, the one the SNI lookup chose; on a resumed connection, the one
|
|
660
694
|
* the lookup chose again for the same name.
|
|
695
|
+
*
|
|
696
|
+
* lemon-tls's certificateObject: the one implementation, the one its
|
|
697
|
+
* TLSSocket and getPeerCertificate use. This built it apiece, with
|
|
698
|
+
* subject and issuer as OpenSSL's text ('CN=a\nO=b') where node:tls
|
|
699
|
+
* gives objects ({ CN: 'a', O: 'b' }).
|
|
661
700
|
*/
|
|
662
701
|
getCertificate: function () {
|
|
663
702
|
var chain = session && session.getCertificate ? session.getCertificate() : null;
|
|
664
703
|
if (!chain || chain.length === 0) return {};
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
var x509 = new X509Certificate(Buffer.isBuffer(der) ? der : Buffer.from(der));
|
|
668
|
-
return {
|
|
669
|
-
subject: x509.subject,
|
|
670
|
-
issuer: x509.issuer,
|
|
671
|
-
subjectaltname: x509.subjectAltName,
|
|
672
|
-
valid_from: x509.validFrom,
|
|
673
|
-
valid_to: x509.validTo,
|
|
674
|
-
fingerprint: x509.fingerprint,
|
|
675
|
-
fingerprint256: x509.fingerprint256,
|
|
676
|
-
serialNumber: x509.serialNumber,
|
|
677
|
-
raw: der
|
|
678
|
-
};
|
|
679
|
-
} catch (e) {
|
|
680
|
-
return {};
|
|
681
|
-
}
|
|
704
|
+
var der = chain[0].cert || chain[0];
|
|
705
|
+
return certificateObject(Buffer.isBuffer(der) ? der : Buffer.from(der)) || {};
|
|
682
706
|
},
|
|
683
707
|
|
|
684
708
|
close: function () { if (session && session.close) session.close(); }
|
package/src/transport.js
CHANGED
|
@@ -734,7 +734,9 @@ function parse_quic_frames(buf) {
|
|
|
734
734
|
frames.push({ type: 'max_stream_data', id: id.value, max: max.value });
|
|
735
735
|
|
|
736
736
|
} else if (type === 0x12 || type === 0x13) {
|
|
737
|
-
|
|
737
|
+
// A count past 2^60 is not one (RFC 9000 §19.11): FRAME_ENCODING_ERROR.
|
|
738
|
+
var at = offset;
|
|
739
|
+
var max = safeReadVarInt(); if (!max || streamCountTooLarge(buf.subarray(at, offset))) break;
|
|
738
740
|
frames.push({ type: type === 0x12 ? 'max_streams_bidi' : 'max_streams_uni', max: max.value });
|
|
739
741
|
|
|
740
742
|
} else if (type === 0x14) {
|
|
@@ -747,7 +749,9 @@ function parse_quic_frames(buf) {
|
|
|
747
749
|
frames.push({ type: 'stream_data_blocked', id: id.value, limit: limit.value });
|
|
748
750
|
|
|
749
751
|
} else if (type === 0x16 || type === 0x17) {
|
|
750
|
-
|
|
752
|
+
// Likewise (§19.14: STREAM_LIMIT_ERROR or FRAME_ENCODING_ERROR).
|
|
753
|
+
var at = offset;
|
|
754
|
+
var limit = safeReadVarInt(); if (!limit || streamCountTooLarge(buf.subarray(at, offset))) break;
|
|
751
755
|
frames.push({ type: type === 0x16 ? 'streams_blocked_bidi' : 'streams_blocked_uni', limit: limit.value });
|
|
752
756
|
|
|
753
757
|
} else if (type === 0x18) {
|
package/src/utils.js
CHANGED
|
@@ -410,7 +410,43 @@ function creditAsRead(readable, credit) {
|
|
|
410
410
|
return function (n) { received += n; };
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
// ── UDP socket buffers ──
|
|
414
|
+
//
|
|
415
|
+
// The kernel's receive buffer is where datagrams wait for the event loop.
|
|
416
|
+
// The OS default (208 KB on Linux) holds some 180 of them: a burst larger
|
|
417
|
+
// than one turn drains is dropped there, silently — loss to QUIC, so a
|
|
418
|
+
// retransmission and a congestion-window cut on a lossless link. 4 MB by
|
|
419
|
+
// default, for the send buffer too (a full one drops what is sent, the same
|
|
420
|
+
// way). The kernel caps both at net.core.rmem_max / wmem_max.
|
|
421
|
+
// socketBufferSize: bytes; 0 or false — the OS default.
|
|
422
|
+
var DEFAULT_SOCKET_BUFFER_SIZE = 4 << 20;
|
|
423
|
+
function checkedSocketBufferSize(v, what) {
|
|
424
|
+
if (v === undefined || v === null) return DEFAULT_SOCKET_BUFFER_SIZE;
|
|
425
|
+
if (v === 0 || v === false) return 0;
|
|
426
|
+
if (!Number.isInteger(v) || v < 0 || v > 0x7fffffff) {
|
|
427
|
+
var e = new RangeError((what || 'socketBufferSize') + ' must be a positive integer (bytes), 0 or false, got ' + v);
|
|
428
|
+
e.code = 'ERR_OUT_OF_RANGE';
|
|
429
|
+
throw e;
|
|
430
|
+
}
|
|
431
|
+
return v;
|
|
432
|
+
}
|
|
433
|
+
// For dgram.createSocket: applied when the socket is bound.
|
|
434
|
+
function udpSocketOptions(base, size) {
|
|
435
|
+
return size > 0 ? Object.assign({}, base, { recvBufferSize: size, sendBufferSize: size }) : base;
|
|
436
|
+
}
|
|
437
|
+
// For a socket already bound. A platform that refuses the size does not stop
|
|
438
|
+
// the socket from working.
|
|
439
|
+
function setSocketBuffers(sock, size) {
|
|
440
|
+
if (!(size > 0)) return;
|
|
441
|
+
try { sock.setSendBufferSize(size); } catch (e) {}
|
|
442
|
+
try { sock.setRecvBufferSize(size); } catch (e) {}
|
|
443
|
+
}
|
|
444
|
+
|
|
413
445
|
export {
|
|
446
|
+
DEFAULT_SOCKET_BUFFER_SIZE,
|
|
447
|
+
checkedSocketBufferSize,
|
|
448
|
+
udpSocketOptions,
|
|
449
|
+
setSocketBuffers,
|
|
414
450
|
createEmitter,
|
|
415
451
|
mergePriority,
|
|
416
452
|
creditAsRead,
|