MoreRSS

site iconEST修改

EST = Extrospect, Sein & Tao ,后端工程师。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

EST的 RSS 预览

Minimal WebRTC handshake

2026-11-09 16:36:00

Author's note: drafted by Deepseek v4.1 Flash


The 23-Byte WebRTC Handshake

How small can a browser-to-browser WebRTC handshake get? I spent a weekend measuring it in Chromium 157 (Microsoft Edge) and Safari 26.2. The short answer: 23 bytes per direction, if you're willing to make a few very specific assumptions.


The 2015 answer was 106 characters

In 2015, Philipp Hancke wrote a great piece called The Minimum Viable SDP. The question was simple: how much do two browsers actually need to exchange before a DataChannel works?

The answer was about 106 characters per direction:

O,<ufrag>,<pwd>,<base64-fingerprint>,<ip>,<port>

That was a hack, and it came with caveats: a TURN relay, no trickle ICE, and a 30-second window to paste the answer. But the core insight was right: most of an SDP is boilerplate, not per-connection state.

I wanted to know what that number looks like today, on current browsers, on a LAN, with no TURN server. So I built a small test harness and measured it.

The result is a nice ladder:

What you send Per direction Assumptions
Full Chrome SDP ~568 B none
Full Safari SDP ~587 B none
Minimal SDP, SHA-256 279 B hand-written SDP, LAN IP known
Minimal SDP, SHA-1 241 B same, SHA-1 (experiment only)
Parameterized, raw, SHA-256 55 B shared template, LAN IP known, port only
Parameterized, raw, SHA-1 43 B same, SHA-1
Pre-shared certificate 23 B fingerprint pinned out-of-band
Custom ICE stack 2 B not browser WebRTC

The interesting part is not the 23 bytes. It's which 23 bytes, and what you have to give up to get there.


What actually has to be exchanged

A WebRTC DataChannel is three layers stacked on top of each other:

  1. ICE gives you a UDP path.
  2. DTLS gives you encryption and identity.
  3. SCTP over DTLS gives you the DataChannel.

Each layer needs a small amount of per-connection state:

Layer What must be exchanged Why
ICE ice-ufrag, ice-pwd, candidate (IP + port) STUN username and MESSAGE-INTEGRITY
DTLS certificate fingerprint authenticate the peer's self-signed cert
DTLS setup role who is DTLS client/server
SCTP sctp-port, max-message-size can be defaulted
Signaling offer/answer role can be implied by message type

Everything else in a normal SDP — codecs, rtpmap, fmtp, extmap, msid, ssrc, rtcp-mux, b=AS, BUNDLE, c= — is either irrelevant for a DataChannel or can be hardcoded.

The per-connection random values are remarkably small:

  • ufrag: 4 characters (24 bits of randomness)
  • pwd: 24 characters in Chrome/Safari (144 bits)
  • DTLS fingerprint: 32 bytes for SHA-256, 20 bytes for SHA-1
  • candidate: 4 bytes IPv4 + 2 bytes port (or less, if the IP is known)

That's the whole handshake, information-theoretically.


The test setup

I used:

  • Chrome/Edge 157 (Chromium 157) headless, driven by Playwright. I used Microsoft Edge as the Chromium stand-in; the SDP and API behavior are the same as Chrome 157.
  • Safari 26.2 on macOS, driven by AppleScript + a local page. No WebDriver permissions needed.
  • A local Node HTTP server for signaling.
  • A DataChannel ping/pong echo to prove the connection actually works.
  • Two topologies: two RTCPeerConnections in the same page, and Chrome page ↔ Safari page.

One important detail: modern browsers hide host candidates behind mDNS:

a=candidate:... 1 udp ... <uuid>.local 64062 typ host

Safari always did this in my tests. Chrome headless did too, unless I passed --disable-features=WebRtcHideLocalIpsWithMdns. A web page cannot turn mDNS off. So the "minimal SDP" numbers depend on whether you send the browser's .local candidate or reconstruct a raw IP:port candidate on the receiving side.

For the LAN experiments, I did the latter: the signaling layer knows the peer's IP, so the candidate only needs the UDP port.


The SDP floor

The SDP parser is stricter than you'd think, but also more forgiving than the spec suggests.

Mandatory at session level:

v=0
o=- 0 0 IN IP4 0
s=-
t=0 0
  • v= must be first.
  • o= must have exactly six space-separated fields. The values are not validated.
  • s= must exist and be non-empty.
  • t= must exist.
  • Lines must be in RFC order. Blank lines are fatal. The last line must end with a newline.
  • LF and CRLF both work. LF saves one byte per line.

Mandatory per DataChannel m-line:

m=application 9 DTLS/SCTP x
a=ice-ufrag:<4+ chars>
a=ice-pwd:<22+ chars>
a=fingerprint:sha-256 <colon-hex>

The m= line needs at least four fields. DTLS/SCTP is the shortest accepted protocol token. The fourth field can be any token; x is the shortest.

That's basically it. You can delete:

  • a=mid — Chrome and Safari accept a single m-line without it; Chrome visibly synthesizes a=mid:0 when parsing the remote SDP. (JSEP says include it; cross-browser code should.)
  • a=sctp-port — defaults to 5000.
  • a=setup — offer defaults to actpass, answer to active.
  • a=group:BUNDLE — optional with the default balanced policy.
  • c= — ICE never uses it.
  • a=ice-options:trickle — not required for addIceCandidate.
  • a=max-message-size — defaults to 64 KiB.
  • a=end-of-candidates — ignored by Chrome.
  • a=msid-semantic, a=extmap-allow-mixed — irrelevant for data.

A working minimal SDP, SHA-256, LF, with a known LAN IP:

v=0
o=- 0 0 IN IP4 0
s=-
t=0 0
m=application 9 DTLS/SCTP x
a=candidate: 1 udp 1 <LAN_IP> <PORT> typ host
a=ice-ufrag:<UFRAG>
a=ice-pwd:<PWD>
a=fingerprint:sha-256 <SHA256_FP>

That's 279 bytes per direction. Swap SHA-256 for SHA-1 and it's 241 bytes. Drop the candidate and trickle it later, and the first SDP is 191 bytes (SHA-1).

Both Chrome and Safari accepted this template. The Safari-specific surprises:

  1. a=sctp-port cannot be combined with DTLS/SCTP x. If you include a=sctp-port, the m-line must be m=application 9 UDP/DTLS/SCTP webrtc-datachannel, otherwise you get sctp-port attribute can't be used with sctpmap.
  2. Safari uses .local candidates by default. If you send the browser's candidate verbatim, the SDP is 308/270 bytes (SHA-256/SHA-1). If you reconstruct the candidate with the known LAN IP, it drops to 279/241 bytes.
  3. Safari accepts SHA-1 fingerprints. It exposes the certificate DER via getStats().base64Certificate, so you can compute a SHA-1 fingerprint yourself. Don't use this in production.

Chrome ↔ Safari works in both directions at 241 bytes with SHA-1, and 279 bytes with SHA-256.


Parameterized signaling: send only what's random

Once you know the SDP floor, the next step is obvious: don't send the SDP at all. Both sides share the same template, and the wire carries only the values that differ.

sender:
  browser creates its normal local SDP (never modify it)
  extract ufrag, pwd, fingerprint, candidate
  encode them into a compact payload
  send

receiver:
  decode
  rebuild the remote SDP from the shared template
  setRemoteDescription(rebuilt SDP)

This is the same idea as the 2015 article, and the same idea as libp2p's WebRTC Direct v2. It also sidesteps the modern SDP munging rules: you never touch the local SDP, so Chrome has nothing to reject.

Packing the values

The ICE alphabet is 64 characters, so each character is exactly 6 bits:

  • 4-char ufrag → 3 bytes
  • 24-char pwd → 18 bytes
  • SHA-256 fingerprint → 32 bytes
  • SHA-1 fingerprint → 20 bytes
  • IPv4 candidate → 4 bytes IP + 2 bytes port
  • port-only → 2 bytes

A raw payload with no flags, no lengths, and no prefix is just:

pack6(ufrag) || pack6(pwd) || fingerprint || candidate
Measured sizes

With a generic header (flags + lengths + p: prefix), base64url text:

Mode SHA-256 SHA-1
full IPv4 85 chars / 62 B 69 chars / 50 B
last two octets 82 chars / 60 B 66 chars / 48 B
port only 80 chars / 58 B 64 chars / 46 B
trickle (no candidate) 77 chars / 56 B 61 chars / 44 B

Without the header/prefix (raw), assuming a fixed 4/24 profile:

Mode SHA-256 SHA-1
full IPv4 79 chars / 59 B 63 chars / 47 B
last two octets 76 chars / 57 B 60 chars / 45 B
port only 74 chars / 55 B 58 chars / 43 B
trickle 71 chars / 53 B 55 chars / 41 B

Every row in that table was verified with a real DataChannel ping/pong in Chrome, Safari, and Chrome ↔ Safari.

If you're sending binary over a WebSocket or DataChannel, use the byte counts. If you're stuck with text, use the character counts.


The 23-byte handshake

The fingerprint is the biggest remaining item: 32 bytes for SHA-256. If both sides already know each other's certificate fingerprint, it doesn't need to be on the wire at all.

Can you just hardcode a certificate in a static page? Not with the standard web API. I checked the API surface in Chrome 157:

new RTCCertificate()
// TypeError: Failed to construct 'RTCCertificate': Illegal constructor

JSON.stringify(cert)
// "{}"

structuredClone(cert)
// true

new RTCPeerConnection({ certificates: [{ getFingerprints: () => [] }] })
// TypeError: Failed to convert value to 'RTCCertificate'

RTCPeerConnection.generateCertificate() is the only factory. There is no import API. You cannot put a PEM private key in a static page and hand it to RTCPeerConnection.

What you can do is generate a certificate once per browser profile, store the RTCCertificate in IndexedDB, and reuse it:

async function getOrCreateCert() {
  const db = await openDb();
  let cert = await idbGet(db, 'dtls');
  if (!cert) {
    cert = await RTCPeerConnection.generateCertificate({
      name: 'ECDSA',
      namedCurve: 'P-256'
    });
    await idbPut(db, 'dtls', cert);
  }
  return cert;
}

const cert = await getOrCreateCert();
const pc = new RTCPeerConnection({ certificates: [cert] });
pc.createDataChannel('x');

RTCCertificate is structured-cloneable, so IndexedDB can store it. Chrome and Safari both keep the same fingerprint across page reloads. I did not separately test a full browser restart, but IndexedDB normally persists.

The first pairing still has to exchange the fingerprint somehow: a QR code, a pairing code, a config file, or a normal first connection. After that, both sides cache peerId -> fingerprint and omit it from the wire.

The payload becomes:

ufrag 3 B + pwd 18 B + port 2 B = 23 B

Or 31 characters as base64url.

That's the floor for standard browser WebRTC: 23 bytes per direction, under these assumptions:

  • Both sides have a persistent certificate and a cached peer fingerprint.
  • The LAN IP is known from signaling.
  • The SDP template, mid, sctp-port, setup, and the role are shared/defaulted.
  • ICE credentials are still generated by the browser, per connection.

You cannot get to zero with standard browser APIs, because ice-ufrag, ice-pwd, and the UDP port are random for every RTCPeerConnection. Only a custom ICE stack can fix those too.

A note on shared certificates

You might be tempted to put one certificate/private key on every device in a "trusted LAN". Please don't.

  • Anyone who can read the page or the device can impersonate every device.
  • One compromised device invalidates the whole identity model.
  • You can't distinguish devices or revoke one without breaking all of them.
  • A copied browser profile may clone the identity anyway.

The sane model is one persistent certificate per device, with fingerprints provisioned or pinned out-of-band. That still gets you the 23-byte handshake, without a single shared secret.


Where does the IP come from?

If the wire only carries a port, the receiver needs the peer's IP from somewhere else.

The browser won't help: it hides local IPs behind mDNS. You need the signaling layer or the OS.

Best for a LAN: let the signaling server observe the source address.

function normalizeIp(addr) {
  if (!addr) return null;
  if (addr.startsWith('::ffff:')) return addr.slice(7);
  if (addr === '::1') return '127.0.0.1';
  return addr;
}

// HTTP: req.socket.remoteAddress
// ws:   req.socket.remoteAddress

Relay that peerIp alongside the signaling message. The receiver rebuilds:

a=candidate: 1 udp 1 <peerIp> <port> typ host

If the signaling server is in the cloud, it sees the peer's public NAT address, not the LAN IP. For a LAN-local signaling server (or direct peer-to-peer signaling), the source address is exactly what you want.

Other options:

  • Direct LAN signaling: if one browser connects to the other over TCP/WebSocket, the remote address is the peer's LAN IP.
  • Same machine: use 127.0.0.1.
  • mDNS: don't guess; send the .local candidate and let the browser resolve it. Costs ~30–42 characters.
  • STUN srflx: gives the address the STUN server sees; useful across NAT, not for discovering a LAN IP.
  • TURN relay: the relay IP is the TURN server's IP (known), only the relay port is dynamic. The "IP known, port only" trick works there too, and it works across NAT.
  • Subnet guessing: if both peers are on the same /24, send the last octet (1 byte) + port (2 bytes). Same /16: send two octets (2 bytes) + port. Unknown: send the full IPv4 (4 bytes) + port. Scanning 254 addresses is not recommended: slow, noisy, and often blocked.
  • Peer-reflexive: for browser-to-server, the server knows its own IP and learns the client's address from the first STUN binding request. The client can omit its candidate entirely. For browser-to-browser, at least one side must know the other's address to send the first packet.

This is why "IP from signaling" isn't cheating: the signaling connection already exists, and its source address is free metadata. The WebRTC payload can therefore be 55 bytes (SHA-256, port-only) or 23 bytes (pre-shared certificate). If you want a self-contained payload with the IP included, add 4 bytes for a full IPv4 address (or 1–2 bytes if you only send a subnet suffix).


Cross-browser notes

Chrome and Safari agreed on more than I expected:

  • Both accept the same minimal m=application 9 DTLS/SCTP x template.
  • Both accept missing a=mid, a=setup, c=, and a=group:BUNDLE for a single m-line.
  • Both accept SHA-1 fingerprints (but don't use SHA-1).
  • Both can persist RTCCertificate in IndexedDB.
  • Both connect with 241 B SDP (SHA-1) and 279 B (SHA-256) in both directions.

The differences:

  • Safari's host candidate is .local; Chrome's can be raw IP with the test flag.
  • a=sctp-port requires UDP/DTLS/SCTP webrtc-datachannel in Safari; DTLS/SCTP x + a=sctp-port is a parse error.
  • Safari's getStats() exposes base64Certificate, which is how I computed SHA-1 fingerprints.

If you want to be conservative (Firefox, future browser changes), keep a=mid:0, a=sctp-port:5000, and a=setup:actpass/active, and use UDP/DTLS/SCTP webrtc-datachannel. That's about 348 bytes per direction — still tiny compared to a full SDP.


Security reality check

The small numbers are fun, but the assumptions matter:

  • SHA-1 is not acceptable in production. It saves 12 bytes of binary / 36 bytes of text and buys you a collision attack. Use SHA-256.
  • The fingerprint is the trust anchor. If signaling is unauthenticated, an attacker can substitute it and MITM the DTLS connection. Use WSS/HTTPS, or sign the payload end-to-end.
  • Don't modify the local SDP's ice-ufrag/ice-pwd. Chrome 137+ rejects it. Generate locally, read the values, rebuild only the remote SDP.
  • Pre-shared fingerprints are certificate pinning. Protect the certificate store, rotate before expires, and authenticate the first pairing.
  • mDNS is a privacy feature. You can't turn it off from a web page. For a compact LAN payload, use the signaling layer's known IP; for the open internet, use STUN/TURN.
  • A shared private key is not a shortcut. Per-device certificates with pinned fingerprints give you the same 23 bytes without a single point of total compromise.

The takeaway

If you only remember one number: 23 bytes per direction for a browser-to-browser DataChannel handshake on a LAN, with a persistent certificate and a pinned fingerprint.

If you need to be practical:

Situation Use
Chrome/Safari, LAN, both ends controlled raw payload + known IP + port-only: 55 B (SHA-256) / 74 chars
Same, with persistent certs 23 B / 31 chars
Must send SDP text, LAN minimal SDP + SHA-256: 279 B
Cross-browser, conservative add mid + sctp-port + setup: ~348 B
Across NAT srflx/relay candidate: at least 6 B candidate + 32 B fingerprint + 21 B ICE credentials

The 2015 article's 106-character answer is still in the right ballpark. What changed is not the information content; it's the dialect (sctpmap → sctp-port), the privacy defaults (mDNS), the SDP munging rules, and the fact that Safari now plays in the same minimal-SDP sandbox.

The most surprising part, to me, is that the browser's own certificate store and IndexedDB let you turn a per-connection DTLS identity into a per-device identity. That's what takes the handshake from 55 bytes to 23 bytes — and it's also the part that deserves the most security thought.

一键清理所有高科技水印工具 「蒸矢」for macOS

2026-11-05 22:16:00

V站看到 警告!分享淘宝截图可能给你带来危险

于是有个很久的 idea 终于得到实施了。

https://github.com/est/Distill-SVG

把蒸馏成矢量图 SVG ,简称 蒸矢 😃

市面上有很多AI辅助把照片转SVG的,但其实矢量图不适合拿来做这事。GUI这类大量色块的截图倒是非常适合。

发行两个 dmg

  • 13MB 完整版,适合普通用户 DistillSVG-arm64-full.dmg
  • 400KB 适合能自己折腾 python 环境的。DistillSVG-arm64-python.dmg

后者需要 pip 装 cv Pillow 等。可以共享已有环境避免重复安装

也可以自己 git clone 命令行操作

转文字通过 macOS 自带 OCR ,图标、头像等会尽可能给你扣出来保留

对于常见的聊天界面加水印够用了。文字识别有问题,你用文本编辑器打开 SVG 改下就行。

如果你说水印藏在头像里,那的确(暂时)还没啥好的办法

目前对于图标太多,需要多处定位的效果很差,蒸矢变蒸💩了。一般的稀疏聊天界面勉强还行

下面是生成的 SVG 样例:

有效果不佳的 case 欢迎提 issue 。只要我 tokens 额度够一定想办法解决 🤣

虽然代码基本都是AI搓的,但也算是我第一个macOS软件作品。

HTML里的“宏”

2026-10-12 21:29:00

摊牌了,分享两个极少有人知道的技巧。

观察下面的网页

https://lab.est.im/xhtml/

有没有发现神奇之处

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html [
  <!ENTITY my_symbol "★">
  <!ENTITY wikipedia '<img height="14" src="https://en.wikipedia.org/favicon.ico" alt="Wikipedia" />'>
]>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>Entity test</title>
  </head>
  <body>
    <p>hey &my_symbol; visit &wikipedia; for more!</p>
  </body>
</html>

另外就是刚做的 play2mi 有一个特立独行的想法:不让出现厂牌的关键字,一律用图标 代替。

但是这个 <img height="16" src="https://www.mi.com/favicon.ico" /> 出现得次数太多,就很重复了。有没有 宏/macro 来节约呢?

浑身不舒服。经过一番激烈拷打AI,最终结果:

div.wrap param {display: inline; height: 1em; vertical-align: -.125em;}
param[mi] { content: url("https://www.mi.com/favicon.ico"); }
param[mijia] { content: url("https://cdn.web-global.fds.api.mi-img.com/mijia/prod/home/favicon.ico") }

<param mi> 是一个自定义属性,写起来比 <i class="icon-mi"></i>短,也不需要闭合。

用不存在的自定义标签当然也行,但必须写一对儿 <mi-icon></mi-icon>。为了不闭合偷懒,查到浏览器只白名单了14个空元素(Void Elements)

  • <meta> 声明页面元数据 <meta charset="UTF-8">
  • <link> 引入外部资源,常用于 CSS、favicon
  • <base> 设置页面中相对 URL 的基础地址 <base href="https://example.com/">
  • <img> 嵌入图片。它是最常用的空元素之一。
  • <source> 为 <picture>、<audio>、<video> 提供可选媒体源。它通常不能独立发挥作用。
  • <track> 给视频或音频添加字幕、说明文字、章节等文本轨道。
  • <embed> 在页面中嵌入外部内容,例如某些 PDF 文档或浏览器支持的嵌入资源。
  • <br> 强制换行 第一行<br>第二行
  • <hr> 横线
  • <wbr> 建议浏览器可在此处换行 superlong<wbr>word
  • <col> 为表格的一列设置样式或属性 <colclass="numeric">
  • <input> 创建文本框、复选框、单选框、文件选择器等表单控件。
  • <area> 在图片映射(image map)中定义一个可点击区域。
  • <param> 为 <object> 提供参数 已废弃,不建议新项目使用

那就变废为宝,拿 <param> 来当 宏 用,不会被当成未知元素,HTML 校验也不报错。

之前的版本还有 css 变量

param {display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; background: var(--icon) center / contain no-repeat;}
param[A] { --icon: url("A.jpg"); }
param[B] { --icon: url("B.jpg") }

也有利用 background-image 比 background 优先

param {display: inline-block; padding: .5em; vertical-align: -.125em; background: center / contain no-repeat;}
param[A] { background-image: url(A.jpg) }
param[B] { background-image: url(B.jpg) }

但 width: 1em 这个太死板。现代css有 content: 更省事儿,宽度还会按图片比例自动算。

AI 还教了一招,用 attr() 给宏传参数

param::after { content: attr(value) }

<param mi value="手机"> <param mi value="电视">

有AI真能学到好多东西,以前翻破书都想不到的,就看你肯不肯开口问。

浏览器点2下就能让小爱音箱播放http音乐!

2026-10-11 16:14:00

还在被 冈易 QQ 音乐困扰吗,让小爱同学播放NAS上的歌需要几步?

https://est.github.io/play2mi/

装个书签,点两下就行。不需要下载软件,不需要第三方服务器

期间还是碰壁了不少的。也学到不少东西。小米音箱以前应该是openwrt魔改 ubus 控制,后来都改成 Android 了。

这一分裂也导致它内部有两套API接口。一个是 sid=micoapi 一个是 sid=xiaomiio,前者跑在 小爱云 Mina api2.mina.mi.com,后者跑在 米家 IoT 云 api.io.mi.com/app 和 api.mijia.tech/app

这次就简单做了 Mina (micoapi) 的支持

能放歌,能 TTS 就行了。要扩展等有缘人吧。

期间搞出来个巨搞笑的插曲,AI 发现浏览器死活登录不上,一度以为是风控了,账号被画像了,IP被标记了,结果是 nonce 计算的时候没用 bigint 精度被截断了。笑死。幸好在去研究 TLS 指纹之前被我拦住了。

这种一般都是低级错误。

然后无聊让AI去扒一下能不能拿到shell,还真有注入漏洞。不过,老固件才行!!气死了!!!!!

算是又填了一个坑吧。对了,这个项目如果想跑在服务器,估计不太行,据说小爱的HTTP对IDC出口有风控。

preconnect 走 http3

2026-10-01 20:33:00

想让 <link rel="preconnect"> 强制走 HTTP/3,能做到吗?

preconnect 是什么

<link rel="preconnect" href="https://example.com">

浏览器看到这个标签,会立刻去做这几件事:

  1. 解析 example.com 域名
  2. 建立一个连接
  3. 完成 TLS 握手(如必要)
  4. 保持连接并等着,不发送任何请求正文

等真正要请求资源时,这个连接已经热好了。这就是 提前连接 preconnect

HTTP/3 跑在 UDP 上(QUIC 协议),HTTP/1.1 和 HTTP/2 跑在 TCP 上。

HTML 层面没有任何办法声明"这个 preconnect 要走 h3"。

这个连接最终用什么协议,HTTP/1.1、HTTP/2、HTTP/3,浏览器有一系列决定流程

触发 h3 preconnect 的三个条件

网络层有两条实现路径(Chromium 正在迁移,两条并存)

条件 ①:浏览器缓存了这个站点的 Alt-Svc

服务端某次响应头带上:

Alt-Svc: h3=":443"; ma=86400

浏览器把它记下来(存进 profile 磁盘缓存),之后所有对该站点的 preconnect 都会走 QUIC。

  • 传统路径:http_stream_factory_job_controller.cc:915-919, 950-968
  • 新路径(HttpStreamPool):http_stream_pool_job_controller.cc:283-288
// If the preconnect explicitly requests QUIC, start preconnecting before
// checking existing SpdySession and idle streams.
if (origin_quic_version_.IsKnown()) {
  preconnect_callback_ = std::move(callback);
  StartAltSvcQuicPreconnect();
}

条件 ②:DNS 的 HTTPS 记录(RFC 9460 SVCB)里 ALPN 含 h3

example.com.  IN HTTPS 1 . alpn="h3,h3-29,h2,h1" port=443

浏览器判定逻辑:

  • 传统路径:http_stream_factory_job_controller.cc:925-932
  • 新路径:http_stream_pool_attempt_manager.cc:1333-1361 → quic_session_pool.cc:1687 SelectQuicVersion()

关键规则:

// net/quic/quic_session_pool.cc:1691
if (metadata.supported_protocol_alpns.empty()) {
  // 纯 A/AAAA 记录 → 除非已知 QUIC 版本,否则这个 endpoint 不合格
  return svcb_optional ? known_quic_version : Unsupported();
}

DNS 只有 A/AAAA 记录时,浏览器根本不会尝试 QUIC,必须 DNS 里明确写 h3。

条件 ③:force_quic_everywhere(理论上行,Chrome 里做不到)

net/quic/quic_context.h:131-133 的 QuicParams 确实能全局强制 QUIC,代码路径也支持:

  • http_stream_factory_job.cc:145-177(using_quic_ 含 ShouldForceQuic)
  • http_stream_pool_job.cc:43-47(group->force_quic() 时只允许 QUIC)

但我把 net/base/switches.h、content_switches.h、chrome_switches.h 都翻了一遍,Chrome 没有任何命令行开关能设置它(老版本的 --force-quic-everywhere 已移除)。只有 Cronet 或自己编译的 embedder 直接构造 QuicContext 才能用。

→ 实际可用方案只有 ① 和 ②。

实施细节

不考虑 Alt-Svc,细说冷连接直接 h3

example.com.  IN HTTPS 1 . alpn="h3,h3-29,h2,h1" port=443

必要条件,缺一不可:

  1. 必须开启 Secure DNS(DoH) —— 这是最容易踩的坑。

Chromium 里 NetworkService 用默认配置创建 DNS 解析器:

// services/network/network_service.cc:483
host_resolver_manager_ = std::make_unique<net::HostResolverManager>(
    net::HostResolver::ManagerOptions(), ...);

默认 insecure_dns_mode = kDisabled(net/dns/host_resolver.h:354),意思是不走 Chromium 内置的明文 DNS 客户端,只走系统 resolver(getaddrinfo)。而系统 resolver 无法查询 type 65。

// net/dns/host_resolver_dns_task.cc:378
if (types.Has(DnsQueryType::HTTPS)) {
  if (!secure() && !client_->CanQueryAdditionalTypesViaInsecureDns(ech_mode_)) {
    https_disabled_ = true;         // 直接把 HTTPS 查询删掉
    types.Remove(DnsQueryType::HTTPS);
  }
}

所以要去 chrome://settings/security → 使用安全 DNS,或用企业策略 SecureDnsMode 配自定义 DoH。你用的 DoH 服务器必须支持 type 65(Cloudflare / Google / Quad9 都支持)。

  1. QUIC 不能被关:chrome://flags/#enable-quic 保持默认(Default = 开)。

  2. origin 未被标记 QUIC broken。

  3. 不能走代理。源码两处都要求直连:

session_->IsQuicEnabled() && proxy_info_.is_direct()

(http_stream_factory_job_controller.cc:925-932)。代理下 preconnect 只会走 TCP。

  1. 必须是 https:HTTP/3 只用于加密连接。

type 65 的两个时序问题

查询顺序上 type 65 排第一(kPrioritizeHttpsResourceRecord 默认开),然后才是 AAAA、A。但实际上 DoH 下三个查询是并发发出的(host_resolver_manager_job.cc:864):

// DoH queries can bypass the dispatcher and start all of their transactions immediately.
if (secure) {
  while (dns_task_->num_additional_transactions_needed() >= 1) {
    dns_task_->StartNextTransaction();
  }
}

所以理论上不存在 HTTPS 单独慢的问题。

type 65 来迟了怎么办? 只有 20% × 50ms 的窗口:

// host_resolver_dns_task.cc:1165
timeout = total_time_for_other_transactions * extra_time_percent / 100;  // 20%
timeout = std::min(timeout, timeout_max);   // 50ms
timeout = std::max(timeout, timeout_min);   // 5ms

举例:A/AAAA 用了 30ms,type 65 只多等 6ms。超时就按"没这条记录"处理。

为什么不等更久?因为三个查询并发在飞,type 65 只要不比 A/AAAA 慢太多就来得及。

降级是静默的

如果 DNS 里没返回 h3 ALPN,QUIC 侧会返回 ERR_DNS_NO_MATCHING_SUPPORTED_ALPN,然后自动回落到 TCP preconnect:

// http_stream_factory_job_controller.cc:586
if (result == ERR_DNS_NO_MATCHING_SUPPORTED_ALPN && preconnect_backup_job_) {
  main_job_ = std::move(preconnect_backup_job_);
  main_job_->Preconnect(num_streams_);

所以配错不会报错,只会静默降级成 TCP。 只能靠 netlog 确认。

同样的降级也发生在 type 65 查询超时、SERVFAIL、REFUSED 时 —— IsFatalTransactionFailure() 对 HTTPS 查询永远返回 false,失败时合成一个空的合法响应,效果等同"这条记录不存在"。preconnect 照样成功,只是走 TCP。

唯一的例外是 DoH + UseDnsHttpsSvcbEnforceSecureResponse=true(默认 false),或 ECH 严格模式,此时失败是致命的 —— 因为必须拿到 SVCB 信息才能继续。普通用户碰不到。

DNS 侧的四个额外坑

非 443 端口不支持。 源码里直接写了注释:

// http_stream_factory_job_controller.cc:113
// Note: Chrome does not yet support different port DNS alpn.

所以 h3 preconnect 实际上只对 443 有效。

ALPN 写了 h2 但没写 h3 反而会堵死 h3。 SelectQuicVersion 一旦看到 SVCB 记录就只认列表里的 h3,找不到直接返回 Unsupported,不会退回"忽略 SVCB 走 QUIC"。也就是说只要发了 HTTPS 记录,就必须在里面写 h3。

HTTPS 记录的 TTL 会拉低整个域名的缓存寿命。 TtlFromInternalResults 取所有查询结果里最短的 TTL(host_cache.cc:729),而 HTTPS metadata 也在这份结果里。别把 HTTPS TTL 单独调小于 A/AAAA,建议保持一致。

ipv4hint/ipv6hint 在稳定版不生效。 代码支持,但受 kUseDnsHttpsSvcbAddressHints 控制,默认关闭(net_base_features.cc:85)。好消息是它不是必需的,A/AAAA 会正常返回 IP。

一个容易误判的点

源码里有"流式返回中间 DNS 结果"的机制(DnsTaskResultsManager),但它的三个开关默认全是关的:

// host_resolver_manager_job.cc:856
if (kEnableIntermediateDnsResults ||     // 默认关
    kAsyncDnsQuicJob ||                  // 默认关
    resolver_->IsHappyEyeballsV3Enabled()) {   // 默认关
  dns_task_results_manager_ = ...;
}

所以当前稳定版 Chrome 实际是"要么全等到齐、要么超时降级",没有真正的流式中间结果。 容易误以为存在该机制。

局限性

坑 说明
粒度只能是 origin 级 Alt-Svc 和 SVCB 都是站点级信息。一旦生效,后续真实请求也会一起走 h3;没法只让 preconnect 走 h3 而普通请求走 h2。
UDP 443 被封 QUIC 握手失败 → 标记 QUIC broken → 之后连 Alt-Svc 都不看,直接退 TCP,还多等一个握手超时。表现为首帧变慢而不是报错。
代理环境下基本失效 方案 B 完全失效(要求直连);方案 A 需要 alt-svc + QUIC 代理同时具备。
第一次 preconnect 不生效 方案 A 必须先有一次真实响应。方案 B 没有这个问题(DNS 直接可查)。
非 https 目标 http:// 永远不会走 h3(GURL::SchemeIsCryptographic 判定)。
crossorigin 属性无用 <link rel=preconnect crossorigin> 只影响 credentials mode 和连接分组键,不影响协议选择。
连接数会被合并/限流 同一 origin 的多个 preconnect 合并到同一个连接池,受上限约束(kDefaultMaxStreamSocketsPerGroup = 6,http_stream_pool.h:110)。
QUIC 只能开 1 条 h3 preconnect 只建 1 个 QUIC session。TCP 侧在服务端支持优先级时也会把预连接数压到 1(http_stream_factory_job.cc:255-270)。
QUIC 被禁后不可恢复 profile 偏好 kQuicAllowed 一旦被企业策略置 false,代码里明确注释 "re-enabling QUIC is not supported",只能重启浏览器或换 profile。

检查清单

  1. 抓 netlog(最权威)
# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --log-net-log --net-log-file=/tmp/netlog.json \
  --net-log-capture-mode=IncludeSensitive

打开 https://netlog-viewer.appspot.com/ 拖进去,搜:

  • HTTP_STREAM_JOB 且 type = preconnect_dns_alpn_h3 → 方案 B 生效
  • HTTP_STREAM_POOL_JOB_ALIVE 里 allowed_alpns 含 h3 → 方案 A 或 B 生效
  • QUIC_SESSION / QUIC_VERSION_NEGOTIATION → 确认真的握手了
  • HOST_RESOLVER_DNS_TASK 的 transactions_needed 里有没有 HTTPS 类型 → 没有就说明压根没发这个查询
  1. 确认 QUIC 没被关
  • chrome://version → 命令行里没有 --disable-quic
  • chrome://policy → 没有禁用 QUIC 的企业策略
  1. 确认 DNS 记录正确
# 直接问你的 DoH(推荐,能验证真实链路)
dig @your-doh-host example.com HTTPS

看 ANSWER 里 alpn= 是否含 h3。注意直接 dig HTTPS example.com 走的是系统 resolver,即使能查到也不代表 Chrome 会用。

  1. 按顺序排查
# 检查项 怎么查
1 DoH 开了吗 chrome://settings/security → "使用安全 DNS" 非"关闭"
2 DoH 服务器支持 type 65 吗 dig @doh-host example.com HTTPS 要有 ANSWER
3 Secure DNS 模式 必须"自动"或"安全"。"关闭" = 完全不发 type 65
4 端口是 443 吗 非 443 不支持
5 ALPN 串里有 h3 吗 注意是 h3 不是 h3-30,建议 h3,h3-29,h2,h1
6 是直连吗 走代理时 DNS-ALPN-h3 被禁用
7 QUIC 没被关吗 flags 默认 + policy 无禁用
8 TTL 合理吗 HTTPS TTL 别短于 A/AAAA
9 origin 没被标 QUIC broken 之前握手失败过会被标记,之后退 TCP 直到重启

源码文件参考

文件 作用
third_party/blink/renderer/core/loader/preload_helper.cc Blink 侧:把 <link rel=preconnect> 变成 preconnect 指令
services/network/network_context.cc 转发给 HttpStreamFactory::PreconnectStreams
net/http/http_stream_factory.cc 传统路径入口,创建 JobController
net/http/http_stream_factory_job_controller.cc 核心判定:何时走 DNS-ALPN-H3 / Alt-Svc,失败如何回落 TCP
net/http/http_stream_factory_job.cc 单个连接任务,using_quic_ 判定
net/http/http_stream_pool_job_controller.cc 新路径(HEv3)preconnect 逻辑
net/http/http_stream_pool_attempt_manager.cc 新路径:QUIC/TCP 连接尝试、竞速
net/quic/quic_session_pool.cc SelectQuicVersion():SVCB ALPN 匹配逻辑
net/http/http_network_session.cc ShouldForceQuic() / IsQuicEnabled()
net/quic/quic_context.h force_quic_everywhere 等参数定义
net/dns/host_resolver.h ManagerOptions.insecure_dns_mode(为什么必须 DoH)
net/dns/host_resolver_dns_task.cc HTTPS RR 查询条件、查询顺序、等待超时预算
net/dns/host_resolver_manager_job.cc DoH 下多查询并发发起
net/dns/host_cache.cc TtlFromInternalResults():缓存 TTL 取各结果最小值
net/dns/public/util.cc GetNameForHttpsQuery():非 443 端口的查询域名格式

Node.js、浏览器和 Cloudflare Workers 的wasm引入问题

2026-09-26 22:51:00

最近在鼓捣 WebAssembly,这玩意没啥神秘的,可以看出一坨二进制的 .js 库文件

它本身的export和调用方式是统一的,但如今天我才知道,在不同 JavaScript 运行环境加载 .wasm 方式并不完全一样。

例如 Cloudflare Workers 可以直接:

import wasm from "./simple.wasm";

const { instance } = await WebAssembly.instantiate(wasm, imports);

Cloudflare 会把 .wasm 模块作为 WebAssembly.Module 提供给代码。

浏览器通常则是通过 fetch() 获取 Wasm,再使用 WebAssembly.instantiateStreaming():

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("./simple.wasm"),
  imports,
);

Node.js 则通过文件系统读取 .wasm 二进制,然后初始化。

.instantiate() 第一参数必须是 typed array 或者 ArrayBuffer

我自己写了个 Wasm 库,AI 为 Node、浏览器、Cloudflare Workers 分别写一套初始化代码,8个 .mjs 文件。给我搞懵了。

提了个简化思路:让初始化函数只接受两种东西:string 表示路径,或者cf那种 WebAssembly.Module 对象

然后搞的时候发现,为了兼容 nodejs,必须 import "node:fs" ,才能读 .wasm 文件嘛

然后逆天的 js 引入必须在 top-level ,在 cf 和浏览器侧就必挂。你 typeof process 提前判断一下环境都不行。

不过AI这时候想到一个聪明办法

if (process?.getBuiltinModule) {
  const fs = process.getBuiltinModule("fs");

  const bytes = await fs.promises.readFile(source);
  return WebAssembly.compile(bytes);
}

卧槽,有这么好的办法,你为啥不早点告诉我。

于是使用方式统一成:

Cloudflare Workers

import wasm from "./simple.wasm";
import { init } from "./foo.mjs";

const foo = await init(wasm);

Browser / Node.js

import { init } from "./foo.mjs";

const foo = await init("./simple.wasm");