ECH + H3 如何接入:让客户端在不暴露 SNI 的前提下又快又稳
摘要
从原理到落地:ECH 隐藏 SNI、H3 改善弱网表现,如何在同一客户端里叠加使用。含 Conscrypt 策略的必踩坑(不设 REQUIRED 会静默不生效)、不依赖 DoH 取 ECH 配置的四种兜底、以及用真机实测判断某域名是否真的支持 H3 的判定口径。
先说个事:连得上,和连得快,完全是两码事
被墙这件事其实分成两个独立的问题。混在一起想,就会一直修不好。
一个是连不上。 TLS 握手的第一个包(ClientHello)里,SNI 是明文的,里面直接写着你要访问的域名。中间设备扫一眼域名,就能把连接掐掉。所谓"域名被墙",技术形态就是这么朴素。
另一个是连得慢。 就算连上了,TCP 在丢包链路上的重传和队头阻塞,能让首屏时间一路恶化。你应该也遇到过这种事:同一个站在浏览器里秒开,放到客户端里转圈半天。这不是代码写得菜,多半是协议选错了。
两个问题各有各的解药:ECH 治第一个,H3(HTTP/3 / QUIC) 治第二个。原理上它们互不相干,可以单用,也可以叠着用。下面是我把它们接进一个 Android 客户端的全过程。
ECH 到底干了什么
一个域名如果支持 ECH,它的 DNS HTTPS 记录(type 65)里会多出一个 ech= 字段,内容是一份 ECHConfigList,通常 71 字节左右。客户端握手时把真正的 SNI 放进加密的内层,外层只露一个没意义的占位名。中间设备就算把外层名字扒出来,也拦不到东西。
有三个点,是我踩过之后才真正明白的:
第一,配置必须用"活值"。 ech= 里的公钥会跟着服务端轮换,短则几小时,长则一天。要是客户端里塞的是静态值、或者缓存放太久,握手会被服务端直接拒掉 —— 看起来"已经支持 ECH 了",实际上全在失败。 稳妥做法是每次都取官方维护的实时值,短周期缓存,取不到再退回域名自己那份记录。
第二,不是所有域名都有 ECH。 只有证书前置在支持的 CDN 上的站点才有这条记录,自建源站根本没有。别浪费时间。
第三,有 ECH 的站点一般也开了 H3,但不保证。 这个后面专门讲怎么实测。
Android 端:把 ECH 做进进程内
Android 上要在这条原生请求链路里用上 ECH,可行的组合是 Conscrypt + OkHttp。把 Conscrypt 当 Provider 装进来,再用它构造 SSLContext:
关键在 `PolicyTrustManager` 要暴露一个公开方法,好让 Conscrypt 通过反射读到域名加密策略:
这一步是我摔得最狠的地方。 少了它,或者策略返回 DISABLED,ECH 不会报任何错,只会静默失效 —— 握手照样成功,你以为加密了,其实 SNI 还是明文躺在那儿。我对着这个"代码明明跑了、网也对,就是不生效"的状态熬了一整晚,最后才想清楚:问题不在于这段代码有没有执行,而在于它有没有被 Conscrypt 认出来。
拿到配置之后,还要逐个 host 注入(OkHttp 的 socket 工厂里正好有这个重载):
最后强烈建议做成 fail-closed:拿不到 ECH 配置时,宁可让这次请求失败,也别回落到明文直连。明文 SNI 等于把要访问的域名写在脸上,那还不如不通。
说说 DoH 和地址
要拿 A 记录和 HTTPS 记录,用 DoH(DNS over HTTPS)最省事:请求 ?name=<域名>&type=A|HTTPS,带上 accept: application/dns-json,返回里 type=65 那段就是 HTTPS 记录。
两个实践上的要点:
地址要优选。 同一个站点在不同线路上的最佳 IP 常常不一样,可以让自己那套 DoH 直接返回优选地址;而 DoH 网关自身的解析,用写死的引导 IP 钉住,绕开先有鸡还是先有蛋。
分清"通用段"和"专用段"。 以 Cloudflare 为例,
162.159.36.0/24这一段只服务 DNS(DoH),拿它当普通业务地址会吃 403;通用段的 IP 才能服务任意业务。把专用段只留给引导、业务地址走通用段,能省掉一类很难查的故障 —— 我在这上面白花了半天。
不依赖 DoH,能用上 ECH 吗?能
这是被问得最多的一个问题,也是我自己真栽过的地方:同一台手机,用流量什么都好,切成 WiFi 就连不上。原因特别朴素 —— 我把所有东西都吊在同一个 DoH 上,它一挂,整个应用直接没网。
所以要让 ECH 不绑死在某一个 DoH 上,得按层次兜底。
一、预置一份配置,完全不联网也能用
ech= 是公开信息,任何网络里查一次就能拿到。把一份当前有效值在构建时打包进客户端,取不到实时值时就先用它。但记住两点:
它只能是兜底:服务端轮换密钥之后(常见几小时到一天),预置值就废了,得随版本更新;
千万别让它当唯一来源,否则一次轮换,全体用户一起失效。
二、取数做成候选链,别写死一个
用户自填的 DoH(填了就优先用);
自建 DoH(用引导 IP 钉住,绕开被污染的解析);
国内可达的公共 DoH —— 实测下来,公共 DoH 返回的
ech=和官方实时值是同一份(比对 ECHConfigList 的前几个字节就能确认),可以放心当备用;本地缓存的"上次成功值"(在有效期内直接用,连网都不用重取)。
任意一环能用就行,别依赖某一个。
三、换个通道:不用 DoH,用最普通的 HTTPS 接口
DoH 只是"用 HTTPS 传 DNS"的一种约定,又不是唯一解法。你完全可以自己起一个 JSON 接口:
这就是个普通 HTTPS 请求:不需要系统 DNS(用引导 IP 直连),不需要客户端支持 DoH,网络里有没有 DoH 都无所谓。
四、地址和配置,分开去取
这点最容易忽略:"解析地址"和"取 ECH 配置"是两件事,完全可以走不同通道。
地址:客户端内置一组优选 IP(或者让用户自己填),压根不查 DNS;
ECH 配置:走上面任意一条通道取。
这样一来,"某个 DoH 用不了"就不会连带把整个网络压垮。反过来说,如果这两样绑在同一个 DoH 上,它一不可达,客户端就 fail-closed 到"整个应用没有网络" —— 这正是我实际撞上的那个故障。
H3:什么时候它真的更快
H3 走 UDP,握手和传输揉在一起,天生没有 TCP 的队头阻塞,还能 0-RTT 恢复。弱网、丢包多的线路上,收益最明显。
客户端这边可以直接上 quiche(Rust 写的,内置 BoringSSL)。它默认不暴露 ECH 接口,但底层 BoringSSL 是有的,补一个导出就行:
于是 H3 和 ECH 能同时用 —— 这在浏览器之外算是少见但确实可行的组合。
但千万别无脑开 H3,一定要按域名实测。 实测里真的存在"ECH 有、H3 没开"的域名:握手能完成,紧接着被对端关掉。判定口径建议这样定:
只要拿到了任何 HTTP 响应(哪怕是 403),就算这条路径支持 H3;只有握手被断或超时,才算不支持。
不然特别容易把"403 拒绝爬虫"误判成"不支持 H3",然后白白放弃一大截速度。
工程上的建议是:H3 只用在图片这类静态资源上,接口和登录仍旧走 TCP + ECH。 前者收益最大、爆炸半径最小。
两条血泪教训
一、别拿机房 IP 测出来的结果当真值。 我一开始在境外的服务器上测,数据漂漂亮亮,回头在真机上完全是另一回事:路由不一样、HTTP 状态码不一样,甚至"这个域名到底支不支持 H3"都可能是相反的答案。有些站点还会专门对机房 IP 和某些地区做限制。所有结论,一律以真机为准。
二、浏览器能打开、客户端打不开的时候,先去抓 HAR。 用浏览器开发者工具导出一份 HAR,把"成功的那条"和"失败的那条"的 URL 形态、请求头、令牌参数摆在一起比 —— 比坐在那儿猜快一百倍。我在这件事上浪费的时间,比所有编码时间加起来还多。
最后
ECH 负责藏 SNI,H3 负责在不稳定的线路上跑得快,两个可以叠着用;
ECH 接入的关键就三件事:策略必须返回 REQUIRED(否则静默失效,这条最坑)、配置取实时活值、逐个 host 注入;
H3 按域名实测再开,只给静态资源用最划算;
别依赖单一 DoH,地址和配置分开取;
所有结论以真机 + HAR 为准,机房 IP 的测试结果看看就好。
