banner
约 2,400 字
8 分钟

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:

kotlin
val tm = PolicyTrustManager(systemTrustManager())
val ctx = SSLContext.getInstance("TLSv1.3", Conscrypt.newProvider())
ctx.init(null, arrayOf(tm), null)

关键在 `PolicyTrustManager` 要暴露一个公开方法,好让 Conscrypt 通过反射读到域名加密策略:

kotlin
class PolicyTrustManager(private val delegate: X509TrustManager) : X509TrustManager {
    private val policy = object : NetworkSecurityPolicy() {
        override fun getDomainEncryptionMode(hostname: String) =
            if (isProtected(hostname)) DomainEncryptionMode.REQUIRED
            else DomainEncryptionMode.DISABLED
    }

    // 名字和签名都不能改,Conscrypt 就是靠反射找它
    fun getNetworkSecurityPolicy(): NetworkSecurityPolicy = policy
}

这一步是我摔得最狠的地方。 少了它,或者策略返回 DISABLED,ECH 不会报任何错,只会静默失效 —— 握手照样成功,你以为加密了,其实 SNI 还是明文躺在那儿。我对着这个"代码明明跑了、网也对,就是不生效"的状态熬了一整晚,最后才想清楚:问题不在于这段代码有没有执行,而在于它有没有被 Conscrypt 认出来。

拿到配置之后,还要逐个 host 注入(OkHttp 的 socket 工厂里正好有这个重载):

kotlin
Conscrypt.setEchConfigList(socket as SSLSocket, echConfigOf(host))

最后强烈建议做成 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= 是公开信息,任何网络里查一次就能拿到。把一份当前有效值在构建时打包进客户端,取不到实时值时就先用它。但记住两点:

  • 它只能是兜底:服务端轮换密钥之后(常见几小时到一天),预置值就废了,得随版本更新;

  • 千万别让它当唯一来源,否则一次轮换,全体用户一起失效。

二、取数做成候选链,别写死一个

  1. 用户自填的 DoH(填了就优先用);

  2. 自建 DoH(用引导 IP 钉住,绕开被污染的解析);

  3. 国内可达的公共 DoH —— 实测下来,公共 DoH 返回的 ech= 和官方实时值是同一份(比对 ECHConfigList 的前几个字节就能确认),可以放心当备用;

  4. 本地缓存的"上次成功值"(在有效期内直接用,连网都不用重取)。

任意一环能用就行,别依赖某一个

三、换个通道:不用 DoH,用最普通的 HTTPS 接口

DoH 只是"用 HTTPS 传 DNS"的一种约定,又不是唯一解法。你完全可以自己起一个 JSON 接口:

纯文本
GET https://<你的域名>/ech?host=example.com
→ { "ech": "<base64 的 ECHConfigList>" }

这就是个普通 HTTPS 请求:不需要系统 DNS(用引导 IP 直连),不需要客户端支持 DoH,网络里有没有 DoH 都无所谓。

四、地址和配置,分开去取

这点最容易忽略:"解析地址"和"取 ECH 配置"是两件事,完全可以走不同通道。

  • 地址:客户端内置一组优选 IP(或者让用户自己填),压根不查 DNS

  • ECH 配置:走上面任意一条通道取。

这样一来,"某个 DoH 用不了"就不会连带把整个网络压垮。反过来说,如果这两样绑在同一个 DoH 上,它一不可达,客户端就 fail-closed 到"整个应用没有网络" —— 这正是我实际撞上的那个故障。

H3:什么时候它真的更快

H3 走 UDP,握手和传输揉在一起,天生没有 TCP 的队头阻塞,还能 0-RTT 恢复。弱网、丢包多的线路上,收益最明显。

客户端这边可以直接上 quiche(Rust 写的,内置 BoringSSL)。它默认不暴露 ECH 接口,但底层 BoringSSL 是有的,补一个导出就行:

纯文本
SSL_set1_ech_config_list(ssl, config, len)

于是 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 的测试结果看看就好。

END