tunnelfetch 1.9.0 → 1.12.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 +432 -71
- package/README.zh-CN.md +82 -31
- package/package.json +1 -1
- package/src/proxy/http-connect.js +11 -13
- package/src/proxy/index.js +11 -1
- package/src/proxy/socks5.js +2 -10
- package/src/proxy/tunnel.js +110 -0
- package/src/tls/connect.js +28 -4
- package/src/tls/handshake-messages.js +13 -4
- package/src/tls/handshake.js +1 -0
- package/src/transport.js +14 -0
- package/src/util/bytes.js +35 -18
- package/types/proxy/index.d.ts +7 -0
- package/types/proxy/tunnel.d.ts +11 -0
- package/types/tls/connect.d.ts +40 -6
- package/types/tls/handshake-messages.d.ts +7 -1
- package/types/util/bytes.d.ts +14 -10
package/README.zh-CN.md
CHANGED
|
@@ -517,17 +517,26 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
|
|
|
517
517
|
|
|
518
518
|
### 一个请求要多少
|
|
519
519
|
|
|
520
|
-
|
|
521
|
-
|
|
520
|
+
通过代理抓取 `sizeorigin/`,传输走 gzip,所有尺寸在**同一次扫描、同一个 isolate** 内跑完,n>=5 取中位数。
|
|
521
|
+
方法写在这里,是因为上一版这张表没有把方法记清楚到能复现的程度:**一个热态页面 = `(reuse=4 - reuse=1) / 3`**,
|
|
522
|
+
也就是连接已经打开之后第 2 到第 4 个页面的成本;最后一列是同一次扫描测出来的新建连接数字。
|
|
522
523
|
|
|
523
|
-
| Body |
|
|
524
|
+
| Body | 热态页面 | 每解压 MB | 新连接上的第一个请求 |
|
|
524
525
|
| --- | --- | --- | --- |
|
|
525
|
-
| 1 KB | 3
|
|
526
|
-
| 16 KB |
|
|
527
|
-
| 64 KB |
|
|
528
|
-
| 256 KB |
|
|
529
|
-
| 1 MB |
|
|
530
|
-
| 4 MB |
|
|
526
|
+
| 1 KB | **1.3 ms** | — | 8 ms |
|
|
527
|
+
| 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
|
|
528
|
+
| 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
|
|
529
|
+
| 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
|
|
530
|
+
| 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
|
|
531
|
+
| 4 MB | **102 ms** | 26 ms/MB | 135 ms |
|
|
532
|
+
|
|
533
|
+
最后一列不是「热态页面 + 一次握手」。4 MB 那一行它比热态页面高 33 ms,而握手只值个位数——因为一条连接上的
|
|
534
|
+
**第一个** body 还要在 V8 把解码循环编译优化之前跑一遍。评估一个新源站要看这一列,不要看第一列。
|
|
535
|
+
|
|
536
|
+
**2026 年 8 月重测,中间几档动了。** 4 MB 那行几乎原样复现(102 对先前发布的 104);64 KB 到 1 MB 比这张表
|
|
537
|
+
原先的数字低 20–30%。那**不是**下面讲的 socket 视图大小改动——在同一个 isolate 里 A/B 新旧视图大小,1 MB 热态
|
|
538
|
+
页面只差约 1 ms——所以它要么是单次扫描看不到的日间波动,要么是被取代的那张表取数方式不同。旧数字已经无从复查,
|
|
539
|
+
这正是把方法写在这里的理由。
|
|
531
540
|
|
|
532
541
|
冷启动成本是**总数**,不是往上面某一行加的增量:
|
|
533
542
|
|
|
@@ -554,35 +563,45 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
|
|
|
554
563
|
### 这些折算成多少钱
|
|
555
564
|
|
|
556
565
|
Workers Standard 每月 $5,含 1000 万请求和 3000 万 CPU 毫秒,超出部分每百万请求 $0.30、每百万 CPU 毫秒
|
|
557
|
-
$0.02
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
|
561
|
-
|
|
|
562
|
-
| 平台 `fetch`,
|
|
563
|
-
|
|
|
564
|
-
|
|
|
565
|
-
|
|
|
566
|
-
|
|
|
567
|
-
|
|
|
568
|
-
|
|
|
566
|
+
$0.02。公式就是 `$5 + max(0, 请求数 - 1000万) x $0.30/百万 + max(0, CPU毫秒 - 3000万) x $0.02/百万`,
|
|
567
|
+
没有别的:
|
|
568
|
+
|
|
569
|
+
| 工作负载 | CPU/请求 | 1000 万/月 | 10 亿/月 |
|
|
570
|
+
| --- | --- | --- | --- |
|
|
571
|
+
| 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $307.40 |
|
|
572
|
+
| 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $365.40 |
|
|
573
|
+
| 连接复用,16 KB 页面 | 2.3 ms | $5.00 | $347.40 |
|
|
574
|
+
| 每请求新建连接,16 KB | 9 ms | $6.20 | $481.40 |
|
|
575
|
+
| 连接复用,1 MB 页面 | 36.3 ms | $11.66 | $1,027.40 |
|
|
576
|
+
| 每请求新建连接,1 MB | 59 ms | $16.20 | $1,481.40 |
|
|
577
|
+
| 连接复用,4 MB 页面 | 102 ms | $24.80 | $2,341.40 |
|
|
578
|
+
| 每请求新建连接,4 MB | 135 ms | $31.40 | $3,001.40 |
|
|
579
|
+
|
|
580
|
+
那两个 `max(0, ...)` 是新加的。上一版这张表把每一个请求、每一毫秒 CPU 都算了钱,忽略了它自己上面那句话
|
|
581
|
+
描述的免费额度——于是 1000 万/月那一列最多高估了 74%($8.70,而实际账单是 $5.00),而在 10 亿那一列误差在
|
|
582
|
+
0.2% 以内,因为那时免费额度只是个舍入误差。高估的方向是对本包不利的,大概这就是它活了这么久的原因。
|
|
569
583
|
|
|
570
584
|
参照那一行给了两个尺寸,因为平台自己的 `fetch` **不是平的**——它按每解压 MB 约 0.82 ms 增长,这是在同一个 CDN 的尺寸阶梯上测的,只有大小在变。把它写成单一的 0.3 ms 再拿去和 4 MB 那一行比,是拿不同的东西相比,而且是**抬高了对手而不是抬高本包**。
|
|
571
585
|
|
|
572
|
-
|
|
586
|
+
这张表原先还按「冷 / 预热」分成两组列。那两组列现在去掉了:新的 CPU 数字全部是热态和新建连接的实测,冷 isolate
|
|
587
|
+
下的同一批负载没有重测过,与其把上一版的冷启动增量套到新基线上凑出四列,不如只写实际测过的两列。冷启动的成本
|
|
588
|
+
在上面那张 `warmup()` 表里,那是一个**总数**,不是往这里某一行上加的增量。
|
|
573
589
|
|
|
574
590
|
#### Chrome 身份的每个选项各花多少
|
|
575
591
|
|
|
576
592
|
上面那张表是默认身份:线上 gzip、AES-256-GCM、x25519。Chrome 那一行把所有变化捆在一起,对做决定没什么用。按"连接复用 + 1 MB 页面 + 十亿请求/月 + 已预热"逐项拆开:
|
|
577
593
|
|
|
578
|
-
| 相对基线的变化 | CPU/请求 | 10
|
|
594
|
+
| 相对基线的变化 | CPU/请求 | 10 亿/月 | Δ | 何时才付 |
|
|
579
595
|
| --- | --- | --- | --- | --- |
|
|
580
|
-
| 基线 —— gzip、AES-256-GCM、x25519 |
|
|
581
|
-
| 源站发 `br` 而不是 gzip |
|
|
582
|
-
| 服务器选中 ChaCha20-Poly1305 |
|
|
583
|
-
| 源站发 `zstd` 而不是 gzip |
|
|
584
|
-
| 协商 X25519MLKEM768,每连接 1 个请求 |
|
|
585
|
-
| 协商 X25519MLKEM768,每连接 20 个请求 |
|
|
596
|
+
| 基线 —— gzip、AES-256-GCM、x25519,热态 | 36.3 ms | $1,027 | — | 总是 |
|
|
597
|
+
| 源站发 `br` 而不是 gzip | 40.5 ms | $1,111 | **+$84** | 源站选择发 `br` |
|
|
598
|
+
| 服务器选中 ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | 服务器优先选它而非 AES |
|
|
599
|
+
| 源站发 `zstd` 而不是 gzip | 39.1 ms | $1,083 | **+$56** | 源站选择发 `zstd` |
|
|
600
|
+
| 协商 X25519MLKEM768,每连接 1 个请求 | 59.2 ms | $1,484 | **+$3** | 每次握手 |
|
|
601
|
+
| 协商 X25519MLKEM768,每连接 20 个请求 | 36.3 ms | $1,028 | **+$0.15** | 同一次握手,摊薄后 |
|
|
602
|
+
|
|
603
|
+
ML-KEM 这两行是拿**新建连接**的 1 MB 基线 59 ms($1,481)来比的,不是拿表头那个热态基线;Δ 列反映的是这一点,
|
|
604
|
+
所以是 $3 而不是旧版写的 $153。那 $153 是「不复用连接」的成本,被算到了后量子密钥交换头上。
|
|
586
605
|
|
|
587
606
|
**最后两行是同样的 0.15 ms ML-KEM,差别完全来自连接复用**——这才是这张表最值得带走的一条。后量子密钥交换在你让 `Client` 活着时是这里最便宜的东西,在你不这么做时是最贵的,因为它是**每握手**,而其余全是每字节。
|
|
588
607
|
|
|
@@ -655,8 +674,40 @@ CPU 计费,所以十亿请求下「预热」两列省下的每月 $65 是净
|
|
|
655
674
|
大 body 的"每字节成本"其实不按字节计——按的是流边界穿越次数。这个运行时的 `DecompressionStream` 以
|
|
656
675
|
4096 字节为块产出输出,套接字单次交付也至多 4096 字节,而每一块在运行时与 JS 之间穿越一次都要几十微秒,
|
|
657
676
|
与块大小无关——实测约 **17 µs 一次穿越**,来自同一个 1 MB 分别按 4 KiB(6.0 ms/MB)到 256 KiB
|
|
658
|
-
(1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB
|
|
659
|
-
|
|
677
|
+
(1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把好几块合并成一次穿越,且只要
|
|
678
|
+
有一个字节就立即以部分填充返回,流式延迟不变。合并多少是**传输层**决定的,不是视图决定的——BYOB 读从不
|
|
679
|
+
等着填满——所以视图开得比传输层单次交给你的多,买不到任何东西,只会白付一次分配。4 MB body 上实测的平均
|
|
680
|
+
填充:直连 37 KB,走代理 8 KB。
|
|
681
|
+
|
|
682
|
+
### 一个在本包赖以存在的那条路径上从未接通的旋钮
|
|
683
|
+
|
|
684
|
+
`openTunnel` 做完 CONNECT(或 SOCKS5)握手时手里握着一个带缓冲的 reader——对端可能把隧道数据和应答塞在
|
|
685
|
+
同一个 chunk 里——它把这个交出去时包成了 `new ReadableStream({ pull })`。逻辑正确,但那是**普通流,不是
|
|
686
|
+
byte stream**。TLS 记录层要 BYOB reader,要不到就静默降级成 default reader,于是*每一条走代理的连接*都丢了
|
|
687
|
+
BYOB,连带 `tls.pullBytes`(它唯一的职责就是决定 BYOB 视图多大)一起失效。
|
|
688
|
+
|
|
689
|
+
同源、1 MB 过记录层、同一 isolate、n=15,修复前:
|
|
690
|
+
|
|
691
|
+
| | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
|
|
692
|
+
|---|---|---|
|
|
693
|
+
| 直连 | min 25, p50 31 | min 73, p50 90 |
|
|
694
|
+
| **走代理** | min 95, p50 111 | min 101, p50 121 |
|
|
695
|
+
|
|
696
|
+
**直连 2.9×,代理 6%**——那个旋钮在代理路径上根本没被读到。连带后果:当初用来定 64 KiB 默认值、标着「对着
|
|
697
|
+
真实代理 socket 扫出来」的那条 U 曲线,是同一个配置的四个采样。
|
|
698
|
+
|
|
699
|
+
`src/proxy/tunnel.js` 把隧道改成 byte stream,并且在握手剩余字节吐完之后**把调用方自己的视图直接递给
|
|
700
|
+
socket**。旋钮接上之后重扫(1 MB 过记录层,p50):
|
|
701
|
+
|
|
702
|
+
| | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
|
|
703
|
+
|---|---|---|---|---|---|
|
|
704
|
+
| 直连 | 21 | 20 | 18 | 22 | 40 |
|
|
705
|
+
| 代理 A | 51 | 61 | 61 | 89 | 152 |
|
|
706
|
+
| 代理 B | 68 | 74 | — | 102 | — |
|
|
707
|
+
|
|
708
|
+
两个独立代理上都单调。默认值因此从 64 KiB 改为 **16 KiB**:三条路径上都在最优列 20% 以内,而 64 KiB 最差
|
|
709
|
+
偏离 45%,分配量还是四分之一,并且正好是一个 TLS 记录。端到端走真 Client + 代理、n=13:1 MB **63 对 78 ms**、
|
|
710
|
+
4 MB **145 对 164 ms**(中位数),4 MB 的最小值在噪声内。
|
|
660
711
|
|
|
661
712
|
读进去的那个视图是 **16 KiB,而且这个尺寸是扫出来的,不是拍的**。它比看上去重要:输入由一个 JS 任务在与
|
|
662
713
|
拉取方相同的事件循环上泵送,所以读到达时解压器手上通常只压着一两块,读回来就是部分填充——实测一个 1 MB
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnelfetch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "A fetch-shaped HTTP client that can route through HTTP CONNECT / HTTPS / SOCKS5 proxies on runtimes with only raw TCP, such as Cloudflare Workers. Implements TLS in userland because the runtime cannot verify a tunnelled peer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fetch",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { ProxyError, LimitError, codes } from '../errors.js';
|
|
15
15
|
import { ByteReader, ByteWriter, latin1, utf8 } from '../util/bytes.js';
|
|
16
|
+
import { tunnelReadable } from './tunnel.js';
|
|
16
17
|
|
|
17
18
|
const CRLFCRLF = utf8('\r\n\r\n');
|
|
18
19
|
const MAX_REPLY_HEADER = 32 * 1024;
|
|
@@ -122,9 +123,15 @@ function buildConnectRequest(proxy, target) {
|
|
|
122
123
|
if (proxy.username) {
|
|
123
124
|
lines.push(`Proxy-Authorization: Basic ${basicCredentials(proxy.username, proxy.password)}`);
|
|
124
125
|
}
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
126
|
+
// `Proxy-Connection` is a pre-standard hop header that never made it into a spec, and clients
|
|
127
|
+
// disagree about it: some send `keep-alive`, some send `close`, some omit it. The proxy sees this
|
|
128
|
+
// even though the origin never does, so for anyone matching a client's behaviour AT THE PROXY it
|
|
129
|
+
// is part of the fingerprint. `keep-alive` stays the default — it avoids a class of proxy that
|
|
130
|
+
// closes the tunnel after one request — but it is no longer fixed.
|
|
131
|
+
//
|
|
132
|
+
// `null` omits the header entirely, which is not the same as sending `close`.
|
|
133
|
+
const pc = proxy.proxyConnection === undefined ? 'keep-alive' : proxy.proxyConnection;
|
|
134
|
+
if (pc !== null) lines.push(`Proxy-Connection: ${pc}`);
|
|
128
135
|
return `${lines.join('\r\n')}\r\n\r\n`;
|
|
129
136
|
}
|
|
130
137
|
|
|
@@ -188,16 +195,7 @@ function replyError(reply, proxy, target, where) {
|
|
|
188
195
|
*/
|
|
189
196
|
function tunnelFrom(socket, reader) {
|
|
190
197
|
return {
|
|
191
|
-
readable:
|
|
192
|
-
async pull(controller) {
|
|
193
|
-
const chunk = await reader.readSome();
|
|
194
|
-
if (chunk === null) controller.close();
|
|
195
|
-
else controller.enqueue(chunk);
|
|
196
|
-
},
|
|
197
|
-
cancel(reason) {
|
|
198
|
-
return reader.cancel(reason);
|
|
199
|
-
},
|
|
200
|
-
}),
|
|
198
|
+
readable: tunnelReadable(socket, reader),
|
|
201
199
|
writable: socket.writable,
|
|
202
200
|
opened: socket.opened,
|
|
203
201
|
close: () => socket.close?.(),
|
package/src/proxy/index.js
CHANGED
|
@@ -19,7 +19,13 @@ import { openSocks5 } from './socks5.js';
|
|
|
19
19
|
* @typedef {(addr: {hostname: string, port: number},
|
|
20
20
|
* opts?: {secureTransport?: 'off'|'on'|'starttls', allowHalfOpen?: boolean}) => Duplex} ConnectFn
|
|
21
21
|
* @typedef {{ protocol: 'http'|'https'|'socks5'|'socks5h', hostname: string, port: number,
|
|
22
|
-
* username?: string, password?: string
|
|
22
|
+
* username?: string, password?: string,
|
|
23
|
+
* proxyConnection?: string | null }} ProxyConfig
|
|
24
|
+
*
|
|
25
|
+
* `proxyConnection` sets the pre-standard `Proxy-Connection` header on a CONNECT request, or
|
|
26
|
+
* omits it entirely when null. Default 'keep-alive'. The origin never sees this header; the
|
|
27
|
+
* proxy does, so it belongs to whatever fingerprint the proxy is reading. Clients disagree —
|
|
28
|
+
* some send keep-alive, some close, some nothing — and omitting is not the same as 'close'.
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
const DEFAULT_PORTS = { http: 8080, https: 443, socks5: 1080, socks5h: 1080 };
|
|
@@ -114,6 +120,10 @@ function normalise(cfg) {
|
|
|
114
120
|
port,
|
|
115
121
|
username: cfg.username || undefined,
|
|
116
122
|
password: cfg.password || undefined,
|
|
123
|
+
// Listed explicitly because this function REBUILDS the config rather than copying it, so a
|
|
124
|
+
// field not named here is dropped without a word. That is how `http2ConnectionWindow` came to
|
|
125
|
+
// be declared in a profile and read by nothing.
|
|
126
|
+
proxyConnection: cfg.proxyConnection,
|
|
117
127
|
});
|
|
118
128
|
}
|
|
119
129
|
|
package/src/proxy/socks5.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { ProxyError, ConfigError, codes, hex8 } from '../errors.js';
|
|
16
16
|
import { ByteReader, ByteWriter, concat, utf8 } from '../util/bytes.js';
|
|
17
|
+
import { tunnelReadable } from './tunnel.js';
|
|
17
18
|
|
|
18
19
|
const VERSION = 0x05;
|
|
19
20
|
const AUTH_VERSION = 0x01;
|
|
@@ -326,16 +327,7 @@ async function readExactly(reader, n, what, where) {
|
|
|
326
327
|
/** Bytes that arrived alongside the reply are tunnel payload; the buffered reader carries them. */
|
|
327
328
|
function tunnelFrom(socket, reader) {
|
|
328
329
|
return {
|
|
329
|
-
readable:
|
|
330
|
-
async pull(controller) {
|
|
331
|
-
const chunk = await reader.readSome();
|
|
332
|
-
if (chunk === null) controller.close();
|
|
333
|
-
else controller.enqueue(chunk);
|
|
334
|
-
},
|
|
335
|
-
cancel(reason) {
|
|
336
|
-
return reader.cancel(reason);
|
|
337
|
-
},
|
|
338
|
-
}),
|
|
330
|
+
readable: tunnelReadable(socket, reader),
|
|
339
331
|
writable: socket.writable,
|
|
340
332
|
opened: socket.opened,
|
|
341
333
|
close: () => socket.close?.(),
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The tunnel's readable half, and why it has to be a byte stream.
|
|
2
|
+
//
|
|
3
|
+
// Both dialects finish their handshake holding a buffered reader that may already carry tunnel
|
|
4
|
+
// payload: an HTTP CONNECT reply is read up to its blank line and a SOCKS5 reply to its fixed
|
|
5
|
+
// length, and in both cases the peer is free to have sent application bytes immediately behind it.
|
|
6
|
+
// Those bytes must be delivered first and in order, which is why the socket cannot simply be
|
|
7
|
+
// handed onward.
|
|
8
|
+
//
|
|
9
|
+
// What this replaces was `new ReadableStream({ pull })` — correct, and a plain stream rather than a
|
|
10
|
+
// byte stream. That distinction is invisible until you look at what reads it. The TLS record layer
|
|
11
|
+
// asks for a BYOB reader and quietly falls back to a default one when it cannot have it, so every
|
|
12
|
+
// connection through a proxy lost BYOB reads, and with them `tls.pullBytes`, whose entire job is to
|
|
13
|
+
// decide how much of the socket arrives per boundary crossing.
|
|
14
|
+
//
|
|
15
|
+
// Measured on the edge against one origin, 4 MB, differenced at a single request:
|
|
16
|
+
//
|
|
17
|
+
// direct 132 socket reads, 31.8 KB average fill
|
|
18
|
+
// proxied 484 socket reads, 8.7 KB average fill
|
|
19
|
+
//
|
|
20
|
+
// and the knob itself, 1 MB through the record layer, n=15 in one isolate:
|
|
21
|
+
//
|
|
22
|
+
// pullBytes 16 KiB pullBytes 1 MiB
|
|
23
|
+
// direct min 25 p50 31 min 73 p50 90 <- 2.9x, the knob works
|
|
24
|
+
// proxied min 95 p50 111 min 101 p50 121 <- 6%, the knob is not read
|
|
25
|
+
//
|
|
26
|
+
// So the U-curve recorded against "a real proxied socket" in util/bytes.js was four samples of one
|
|
27
|
+
// configuration. The knob was never reaching the code on that path.
|
|
28
|
+
//
|
|
29
|
+
// The fix is not to copy harder. Once the handshake's leftovers are drained this hands the caller's
|
|
30
|
+
// own view straight to the socket, so a read through a tunnel costs exactly what a read without one
|
|
31
|
+
// costs — no wrapper copy, and the same coalescing.
|
|
32
|
+
|
|
33
|
+
import { ByteReader } from '../util/bytes.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* View size handed to a *default* reader of this stream. BYOB readers supply their own view and
|
|
37
|
+
* never see this; it exists so that a caller which does not do BYOB still gets socket-sized chunks
|
|
38
|
+
* rather than whatever the transport felt like emitting.
|
|
39
|
+
*/
|
|
40
|
+
const TUNNEL_CHUNK = 65536;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Wrap a proxy handshake's buffered reader plus its socket as one byte stream.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ readable: ReadableStream<Uint8Array> }} socket the raw transport
|
|
46
|
+
* @param {import('../util/bytes.js').ByteReader} reader the handshake's reader, possibly holding
|
|
47
|
+
* bytes that belong to the tunnel
|
|
48
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
49
|
+
*/
|
|
50
|
+
export function tunnelReadable(socket, reader) {
|
|
51
|
+
/** Taken once `reader` runs dry, after which every read goes straight to the socket. */
|
|
52
|
+
let direct = null;
|
|
53
|
+
/** Buffered stand-in for `reader` when the socket turns out not to be a byte stream. */
|
|
54
|
+
let buffered = reader;
|
|
55
|
+
let promoted = false;
|
|
56
|
+
|
|
57
|
+
// Whether a stream is BYOB-capable can only be discovered by asking it for a BYOB reader, and it
|
|
58
|
+
// cannot be asked while the handshake's reader holds the lock. So: release (safe only because
|
|
59
|
+
// the guard below proves nothing is buffered), ask, and on refusal re-take a buffered reader —
|
|
60
|
+
// which is exactly the shape that was here before, for transports that cannot do better. Every
|
|
61
|
+
// in-process stream in this package's tests is one of those, so this path is well covered.
|
|
62
|
+
const promote = () => {
|
|
63
|
+
if (promoted || buffered.buffered > 0 || buffered.atEof) return;
|
|
64
|
+
promoted = true;
|
|
65
|
+
buffered.releaseLock();
|
|
66
|
+
try {
|
|
67
|
+
direct = socket.readable.getReader({ mode: 'byob' });
|
|
68
|
+
} catch {
|
|
69
|
+
buffered = new ByteReader(socket.readable);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return new ReadableStream({
|
|
74
|
+
type: 'bytes',
|
|
75
|
+
autoAllocateChunkSize: TUNNEL_CHUNK,
|
|
76
|
+
async pull(controller) {
|
|
77
|
+
// autoAllocateChunkSize guarantees a byobRequest even for a default reader, so there is one
|
|
78
|
+
// path here rather than two.
|
|
79
|
+
const req = controller.byobRequest;
|
|
80
|
+
const view = req.view;
|
|
81
|
+
promote();
|
|
82
|
+
if (direct) {
|
|
83
|
+
const { value, done } = await direct.read(
|
|
84
|
+
new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
|
|
85
|
+
);
|
|
86
|
+
if (done) {
|
|
87
|
+
// Close first: a BYOB request may only be answered with zero bytes once the stream is
|
|
88
|
+
// closed, and the read has detached the original view, so the answer has to be the
|
|
89
|
+
// zero-length view the reader handed back rather than a plain respond(0).
|
|
90
|
+
controller.close();
|
|
91
|
+
req.respondWithNewView(value ?? new Uint8Array(0));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
req.respondWithNewView(value);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const chunk = await buffered.readSome(view.byteLength);
|
|
98
|
+
if (chunk === null) {
|
|
99
|
+
controller.close();
|
|
100
|
+
req.respond(0);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
new Uint8Array(view.buffer, view.byteOffset, view.byteLength).set(chunk);
|
|
104
|
+
req.respond(chunk.byteLength);
|
|
105
|
+
},
|
|
106
|
+
cancel(reason) {
|
|
107
|
+
return direct ? direct.cancel(reason) : buffered.cancel(reason);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
package/src/tls/connect.js
CHANGED
|
@@ -137,9 +137,23 @@ function expectServerHello(msg, offers12) {
|
|
|
137
137
|
* @property {number[]} [groups] supported_groups, in preference order.
|
|
138
138
|
* @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
|
|
139
139
|
* supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
|
|
140
|
-
* @property {number[]} [ciphers] cipher suites to offer, in preference order.
|
|
141
|
-
* one this package can perform
|
|
142
|
-
* server selects it, so an unknown suite is refused here rather than on the wire.
|
|
140
|
+
* @property {number[]} [ciphers] cipher suites to offer, in preference order. By default every
|
|
141
|
+
* suite must be one this package can perform: an offer it cannot honour is a dead connection the
|
|
142
|
+
* moment a server selects it, so an unknown suite is refused here rather than on the wire.
|
|
143
|
+
* @property {number[]} [omitExtensions] extension types to leave out of the ClientHello, the
|
|
144
|
+
* subtractive counterpart to `extraExtensions`. `status_request` (5) is the one extension this
|
|
145
|
+
* package sends that curl does not, so an identity matching a sample without it needs this.
|
|
146
|
+
* Dropping it gives up OCSP stapling, which is the only revocation signal this package can
|
|
147
|
+
* consume — pairing it with `trust.revocation: 'require-staple'` is refused rather than left to
|
|
148
|
+
* fail every connection.
|
|
149
|
+
* @property {boolean} [allowUnperformableCiphers] offer suites this package cannot complete.
|
|
150
|
+
* For fingerprint fidelity only. Real clients offer far more than this package implements — curl
|
|
151
|
+
* 8.21.0 offers thirty against seven performable here, Chromium fifteen against seven — so a
|
|
152
|
+
* hello restricted to what it can honour carries a cipher list shorter than any real client's,
|
|
153
|
+
* which is exactly what a JA3 hash reads. With this set, a server that selects an unperformable
|
|
154
|
+
* suite fails the handshake; the first such suite sits behind the TLS 1.3 ones in both real
|
|
155
|
+
* lists, so a 1.3-capable server does not reach it. Knowingly trading a rare failure for an
|
|
156
|
+
* accurate fingerprint is a legitimate choice; making it silently is not.
|
|
143
157
|
* @property {Uint8Array[]} [extraExtensions] pre-encoded ClientHello extensions, appended before
|
|
144
158
|
* ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
|
|
145
159
|
* exists and sorts that — so ordering alone cannot produce an extension this package does not
|
|
@@ -347,7 +361,16 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
|
|
|
347
361
|
// package cannot perform is an offer a server may take and then find unhonoured, which fails the
|
|
348
362
|
// connection rather than merely looking wrong". It applies just as much to `tls.ciphers`, and
|
|
349
363
|
// now does. CIPHER_PARAMS is the set this package knows how to key and seal.
|
|
350
|
-
|
|
364
|
+
//
|
|
365
|
+
// `allowUnperformableCiphers` opts out, and it exists because refusing outright was the wrong
|
|
366
|
+
// default to have no escape from. Every real client offers far more suites than this package
|
|
367
|
+
// implements — curl 8.21.0 offers thirty and seven are performable here; Chromium offers fifteen
|
|
368
|
+
// and seven are — so a hello restricted to what can be honoured has a cipher list shorter than
|
|
369
|
+
// any real client's, and that is itself what a JA3 hash reads. A caller matching a fingerprint
|
|
370
|
+
// may rationally prefer the accurate list: the first unperformable suite sits at index 5 of
|
|
371
|
+
// curl's and 7 of Chromium's, behind the TLS 1.3 suites, so a server with 1.3 available never
|
|
372
|
+
// reaches it. The risk is real but narrow, and it is the caller's to take knowingly.
|
|
373
|
+
if (options.ciphers && !options.allowUnperformableCiphers) {
|
|
351
374
|
const unperformable = ciphers.filter((c) => !CIPHER_PARAMS[c]);
|
|
352
375
|
if (unperformable.length) {
|
|
353
376
|
throw new TlsError(
|
|
@@ -414,6 +437,7 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
|
|
|
414
437
|
alpn,
|
|
415
438
|
ciphers,
|
|
416
439
|
extraExtensions: options.extraExtensions ?? [],
|
|
440
|
+
omitExtensions: options.omitExtensions ?? [],
|
|
417
441
|
versions,
|
|
418
442
|
extensionOrder: options.extensionOrder,
|
|
419
443
|
sigSchemes: options.sigSchemes,
|
|
@@ -192,6 +192,7 @@ export async function deriveSharedSecret(group, privateKey, peerKey, deps = {})
|
|
|
192
192
|
* @property {string[]} [alpn] default ['http/1.1']; empty array omits the extension
|
|
193
193
|
* @property {number[]} [versions] default [TLS13, TLS12]
|
|
194
194
|
* @property {Uint8Array[]} [extraExtensions] pre-encoded, sent verbatim (the HRR cookie)
|
|
195
|
+
* @property {number[]} [omitExtensions] extension types to leave out of the hello
|
|
195
196
|
* @property {{ identity: Uint8Array, obfuscatedTicketAge: number, binderLen: number }} [psk]
|
|
196
197
|
* offer this resumption PSK. Encoded with a zeroed binder placeholder; the caller MUST derive
|
|
197
198
|
* the real binder over `message.subarray(0, truncatedLength)` and patch it in at
|
|
@@ -299,6 +300,7 @@ export function buildClientHello({
|
|
|
299
300
|
versions = [TLS13, TLS12],
|
|
300
301
|
extensionOrder = CURL_EXTENSION_ORDER,
|
|
301
302
|
extraExtensions = [],
|
|
303
|
+
omitExtensions = [],
|
|
302
304
|
psk = null,
|
|
303
305
|
grease = false,
|
|
304
306
|
randomBytes = defaultRandom,
|
|
@@ -341,11 +343,15 @@ export function buildClientHello({
|
|
|
341
343
|
'a resumption PSK was supplied but TLS 1.3 is not among the offered versions');
|
|
342
344
|
}
|
|
343
345
|
|
|
346
|
+
const omit = new Set(omitExtensions);
|
|
344
347
|
const extensionParts = [
|
|
345
348
|
encodeServerName(hostname),
|
|
346
|
-
//
|
|
347
|
-
// a stapled OCSP response is the only revocation signal this package can consume.
|
|
348
|
-
|
|
349
|
+
// Offered by default for either version: without it a server may not staple (RFC 6066 s8), and
|
|
350
|
+
// a stapled OCSP response is the only revocation signal this package can consume. It is also
|
|
351
|
+
// the one extension this package sends that curl does not, so an identity matching a sample
|
|
352
|
+
// without it needs a way to drop it — `omitExtensions` is the subtractive counterpart to
|
|
353
|
+
// `extraExtensions`, and dropping this one gives up stapling.
|
|
354
|
+
omit.has(EXTENSION.status_request) ? null : encodeStatusRequest(),
|
|
349
355
|
encodeSupportedGroups(g ? [g.take(), ...groups] : groups),
|
|
350
356
|
encodeSignatureAlgorithms(sigSchemes),
|
|
351
357
|
alpn.length ? encodeAlpn(alpn) : null,
|
|
@@ -589,7 +595,10 @@ export function negotiateCipher(serverHello, { offeredCiphers, version }) {
|
|
|
589
595
|
if (!params) {
|
|
590
596
|
throw new TlsUnsupportedError(
|
|
591
597
|
codes.TLS_CIPHER_UNSUPPORTED,
|
|
592
|
-
`cipher suite ${hex16(suite)}
|
|
598
|
+
`cipher suite ${hex16(suite)} was offered but this package cannot perform it, so the ` +
|
|
599
|
+
'handshake cannot continue. Either the offer list has a bug, or `allowUnperformableCiphers` ' +
|
|
600
|
+
'was set to match a real client\'s cipher list and a server has selected one of the suites ' +
|
|
601
|
+
'that choice knowingly put on the wire.',
|
|
593
602
|
{ cipherSuite: suite },
|
|
594
603
|
);
|
|
595
604
|
}
|
package/src/tls/handshake.js
CHANGED
|
@@ -250,6 +250,7 @@ export async function continueTls13(ctx) {
|
|
|
250
250
|
// not permit a second hello to change its extension SET, and a retry that quietly dropped
|
|
251
251
|
// them would present one fingerprint on the first flight and a different one on the second —
|
|
252
252
|
// a difference that is itself a signal.
|
|
253
|
+
omitExtensions: options.omitExtensions ?? [],
|
|
253
254
|
extraExtensions: [
|
|
254
255
|
...(options.extraExtensions ?? []),
|
|
255
256
|
...(cookie ? [cookieExtension(cookie)] : []),
|
package/src/transport.js
CHANGED
|
@@ -124,6 +124,20 @@ export async function openConnection({
|
|
|
124
124
|
}) {
|
|
125
125
|
const target = targetFromUrl(url);
|
|
126
126
|
const proxyConfig = parseProxy(proxy);
|
|
127
|
+
|
|
128
|
+
// Dropping status_request gives up OCSP stapling, and stapling is the only revocation signal this
|
|
129
|
+
// package consumes. Combined with `require-staple` that is not a weakened check, it is a policy
|
|
130
|
+
// that can never be satisfied: every connection would fail with OCSP_REQUIRED, on a certificate
|
|
131
|
+
// that was never asked to carry a staple. Refused here, where both settings are visible.
|
|
132
|
+
if ((tls?.omitExtensions ?? []).includes(5) && trust?.revocation === 'require-staple') {
|
|
133
|
+
throw new ConfigError(
|
|
134
|
+
codes.CONFIG_INVALID,
|
|
135
|
+
"tls.omitExtensions drops status_request (5) while trust.revocation is 'require-staple'. " +
|
|
136
|
+
'Without status_request a server is not asked to staple, so no staple can arrive and every ' +
|
|
137
|
+
'connection would fail. Keep the extension, or relax the revocation policy — the two are ' +
|
|
138
|
+
'a contradiction rather than a stricter setting.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
127
141
|
const owns = !deadlines;
|
|
128
142
|
const dl = deadlines ?? new DeadlineController({}, { signal });
|
|
129
143
|
|
package/src/util/bytes.js
CHANGED
|
@@ -15,16 +15,29 @@ const EMPTY = new Uint8Array(0);
|
|
|
15
15
|
* so a large view never delays delivery; it only lets bytes the transport has already buffered
|
|
16
16
|
* arrive in one crossing instead of many.
|
|
17
17
|
*/
|
|
18
|
-
// Swept on the edge
|
|
18
|
+
// Swept on the edge, ms of CPU for a 1 MB body at the record layer, p50 of n>=7 in one isolate,
|
|
19
|
+
// each column an independent path against the same origin:
|
|
19
20
|
//
|
|
20
|
-
//
|
|
21
|
+
// 8 KiB 16 KiB 32 KiB 64 KiB 256 KiB
|
|
22
|
+
// direct 21 20 18 22 40
|
|
23
|
+
// proxy A 51 61 61 89 152
|
|
24
|
+
// proxy B 68 74 - 102 -
|
|
21
25
|
//
|
|
22
|
-
//
|
|
23
|
-
// the
|
|
24
|
-
// the
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// Monotonic on both proxies, and shallow on the direct path: too LARGE is what costs, because a
|
|
27
|
+
// BYOB read resolves the instant any byte exists and never waits to fill, so a view bigger than
|
|
28
|
+
// what the transport hands over per read is allocation that is never used. Average fill measured
|
|
29
|
+
// over a 4 MB body: 37 KB direct, 8 KB through a proxy — which is why the two paths want different
|
|
30
|
+
// numbers and why the proxied one wants a small one.
|
|
31
|
+
//
|
|
32
|
+
// 16 KiB is within ~20% of the best figure on all three paths; the 64 KiB that used to sit here is
|
|
33
|
+
// up to 45% off. It is also exactly one TLS record, which is the unit the caller above asks for.
|
|
34
|
+
//
|
|
35
|
+
// The previous value came from a sweep captioned "against a real proxied socket" that reported a
|
|
36
|
+
// clean U with its floor at 64 KiB. That sweep measured nothing: openTunnel wrapped the socket in a
|
|
37
|
+
// plain ReadableStream, so the record layer could not take a BYOB reader on any proxied connection
|
|
38
|
+
// and this constant was never read on that path. The four numbers were four samples of one
|
|
39
|
+
// configuration. See proxy/tunnel.js, which is where that was fixed.
|
|
40
|
+
const BYOB_PULL_BYTES = 16384;
|
|
28
41
|
|
|
29
42
|
/** Raised when the peer stops sending in the middle of a structure we must read whole. */
|
|
30
43
|
export class UnexpectedEofError extends TunnelFetchError {
|
|
@@ -50,21 +63,25 @@ export class UnexpectedEofError extends TunnelFetchError {
|
|
|
50
63
|
* be retained beyond the caller's immediate use if memory matters.
|
|
51
64
|
*
|
|
52
65
|
* When the source is a byte stream — on the target runtime, a socket's readable is one — the
|
|
53
|
-
* reader pulls with BYOB reads
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
66
|
+
* reader pulls with BYOB reads instead of taking the source's own chunking. This is measured, not
|
|
67
|
+
* stylistic: the runtime delivers socket data in chunks of at most 4096 bytes, ~1200 of them for a
|
|
68
|
+
* 4 MB body, and every chunk is a runtime/JS boundary crossing; a BYOB read collects several of
|
|
69
|
+
* them into one.
|
|
70
|
+
*
|
|
71
|
+
* How MANY it collects is the transport's decision, not the view's. A BYOB read resolves the
|
|
72
|
+
* instant any byte is available and never waits to fill, so the view is a ceiling that is normally
|
|
73
|
+
* not reached: measured over a 4 MB body, 37 KB average fill on a direct socket and 8 KB through a
|
|
74
|
+
* proxy, whatever the view size. Sizing the view far above that buys nothing and costs the
|
|
75
|
+
* allocation — see BYOB_PULL_BYTES. Sources that are not byte streams (every in-process
|
|
59
76
|
* ReadableStream in this package and its tests) take the default-reader path unchanged.
|
|
60
77
|
*/
|
|
61
78
|
export class ByteReader {
|
|
62
79
|
/**
|
|
63
80
|
* @param {ReadableStream<Uint8Array>} readable
|
|
64
|
-
* @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
81
|
+
* @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because the
|
|
82
|
+
* right value depends on how much the transport hands over per read, and that differs by a
|
|
83
|
+
* factor of four between a direct socket and a proxied one — see BYOB_PULL_BYTES for the sweep.
|
|
84
|
+
* Ignored on sources that are not byte streams, which take the default-reader path.
|
|
68
85
|
*/
|
|
69
86
|
constructor(readable, pullBytes = BYOB_PULL_BYTES) {
|
|
70
87
|
this._pullBytes = pullBytes > 0 ? pullBytes : BYOB_PULL_BYTES;
|
package/types/proxy/index.d.ts
CHANGED
|
@@ -49,12 +49,19 @@ export type ConnectFn = (addr: {
|
|
|
49
49
|
secureTransport?: "off" | "on" | "starttls";
|
|
50
50
|
allowHalfOpen?: boolean;
|
|
51
51
|
}) => Duplex;
|
|
52
|
+
/**
|
|
53
|
+
* `proxyConnection` sets the pre-standard `Proxy-Connection` header on a CONNECT request, or
|
|
54
|
+
* omits it entirely when null. Default 'keep-alive'. The origin never sees this header; the
|
|
55
|
+
* proxy does, so it belongs to whatever fingerprint the proxy is reading. Clients disagree —
|
|
56
|
+
* some send keep-alive, some close, some nothing — and omitting is not the same as 'close'.
|
|
57
|
+
*/
|
|
52
58
|
export type ProxyConfig = {
|
|
53
59
|
protocol: "http" | "https" | "socks5" | "socks5h";
|
|
54
60
|
hostname: string;
|
|
55
61
|
port: number;
|
|
56
62
|
username?: string;
|
|
57
63
|
password?: string;
|
|
64
|
+
proxyConnection?: string | null;
|
|
58
65
|
};
|
|
59
66
|
import { openDirect } from './direct.js';
|
|
60
67
|
import { openHttpConnect } from './http-connect.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a proxy handshake's buffered reader plus its socket as one byte stream.
|
|
3
|
+
*
|
|
4
|
+
* @param {{ readable: ReadableStream<Uint8Array> }} socket the raw transport
|
|
5
|
+
* @param {import('../util/bytes.js').ByteReader} reader the handshake's reader, possibly holding
|
|
6
|
+
* bytes that belong to the tunnel
|
|
7
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
8
|
+
*/
|
|
9
|
+
export function tunnelReadable(socket: {
|
|
10
|
+
readable: ReadableStream<Uint8Array>;
|
|
11
|
+
}, reader: import("../util/bytes.js").ByteReader): ReadableStream<Uint8Array>;
|