服务器名称指示(英语:Server Name Indication,缩写:SNI)是TLS的一个扩展协议[1],在该协议下,在握手过程开始时客户端告诉它正在连接的服务器要连接的主机名称。这允许服务器在相同的IP地址和TCP端口号上呈现多个证书,并且因此允许在相同的IP地址上提供多个安全(HTTPS)网站(或其他任何基于TLS的服务),而不需要所有这些站点使用相同的证书。它与HTTP/1.1基于名称的虚拟主机的概念相同,但是用于HTTPS。所需的主机名未加密,[2] 因此窃听者可以查看请求的网站。
SNI
定义参考维基百科:服务器名称指示
重要:按照 Apple 的说法,它支持 SNI,只不过是 Host name 形式的,参考:https://forums.developer.apple.com/thread/105057。只不过之前 Apple 也确反对过直接使用 IP 地址,所以在 URLSession 这一层没有任何关于 SNI 的接口。
HTTPDNS
一种通过 http 协议获取 Host 所对应的 IP 的技术,以对抗 DNS 污染。
通常形式是:
GET http://119.29.29.29/d?dn=yuhanle.com&ip=111.200.23.6
其中,
119.29.29.29
是 HTTPDNS 服务提供商的 IP(因为直接使用了 IP,因此不存在 DNS 查询),dn=yuhanle.com
是需要查询的 Host,ip=111.200.23.6
是查询发起客户端的 IP。这个请求会返回一个 IP 地址,对应要查询的 Host,后续客户端可以使用这个 IP 来做实际的请求,再次避免 DNS 查询。
问题概述
对于单 IP 单域名的场景,在开启 HTTPDNS 的情况下,出现证书校验问题时,客户端(iOS)比较好做 Hack,参见 https://kangzubin.com/httpdns-https/,目前 Quicksilver 已有类似处理。
HTTPDNS + HTTPS + SNI
简单来说,这三者全都有时,就生出我们现在面对的问题。
调研方案
搜索了一些 iOS SNI 相关的资料,中文资料有一些,英文资料几乎没有。
Hook URLSession or CFNetwork
希望找到某个可以 Hook 的入口,以干预 SSL 握手。但目前没有找到可用的入口(不然市面上应该会有相关讨论或实现)。
Apple 开源的 CFNetwork 版本和其在最近 iOS 版本里使用的并不一样。开源的可在 https://opensource.apple.com/source/CFNetwork/ 找到,最近版本是
CFNetwork-129.20
,据查最新 iOS 里使用的是CFNetwork-978.0.7
,版本差别很大,新的也未开源。CURL
curl 是流行的开源网络请求工具,它很早(v7.21.3)就支持 SNI(或者说 IP 形式的 SNI)。它的原理是使用用户传入的参数,在 SSL 握手时使用正确的 Host 而不是 IP,以避免证书校验问题。
利用
--resolve
参数实现 SNI 支持curl https://neo-dev-feature.yuhanle.com/api/debug --resolve neo-dev-feature.yuhanle.com:443:47.246.18.233
可以看到
--resolve neo-dev-feature.yuhanle.com:443:47.246.18.233
的格式为Host:Port:Address
,其中 Port 通常可能为 80(HTTP)或 443(HTTPS),Address 可以是 Host 所对应的多个 IP 地址中的一个。具体看其文档,Address 也可一次指定多个。测试 CURL 的可行性
集成
libcurl
到 iOS Swift Framework,此处省略 1000 字……其中设置 SNI 的代码片段(对应上面的
--resove
参数):CFNetwork
大约10年前,出现一个 ASIHTTPRequest 网络库,它基于 CFNetwork 实现,但很久没维护(5年)。它参考了 Apple 的一个样例代码:ImageClient,后者出现于2005年,没再更新。
因为 CFNetwork 是非常底层的网络实现,短期研究不会有具体收益。此外,里面用到的一些 API 已经被 Apple 在 iOS 9 就标记为废弃。所以就算暂时能用,将来也可能不能用。
CFNetwork + URLProtocol
利用 URLProtocol 拦截 https 请求,再利用 CFNetwork 去设置 SNI 并做实际的请求。URLProtocol 拦截也有一些问题,例如 POST 可能丢 Body。
有简单的 Demo:SNINetworkDemo
Chrome's net
Chrome 所使用的网络库,代码可在 https://github.com/chromium/chromium/tree/master/net 找到。它使用 C++ 实现,理论上可以跨平台,不过已超出我的能力范围,未研究。
结论
后续
Quicksilver
集成它,并提供配置接口,可在之前的实现和基于 curl 的实现之间切换。这样对于业务方更友好,除了配置,不用或者少量修改业务代码。CURL + URLProtocol
,一样在Quicksilver
里利用 URLProtocol 拦截 https 请求,再用 curl 来执行具体请求,一样需要对付 URLProtocol 方面的问题。问题的本质是 HTTPDNS + HTTPS + SNI 三者同时存在,Apple 明确反对直接使用 IP 请求,因此 iOS 不支持 IP 形式的 SNI。
方案
小背景
假定外部网络条件在大多数情况下都是正常的,少数情况下,例如某些运营商在特定时段才有 DNS 污染,而且网络链路中不稳定的节点可能小概率随机出现 服务端有动态 CDN 版本的域名,也就是有两个域名
方案
基于当前网络库,设计其内部降级方案(其中一种策略):
如果我们在网络库中集成 Cronet,我们就可以增加第 D 步:
D:在 C 失败时,依然适用动态 CDN 版本的域名,开启 HTTPDNS,使用 Cronet 再重新发送请求 或者,直接将第 C 步改为:
C:在 B 失败时,切换请求域名到动态 CDN 的版本,开启 HTTPDNS,使用 Cronet 再重新发送请求
如图:
无论哪种方案,都不能解决 WKWebView 里的问题。
网络库改造
原则
尽量少的修改 API,提供迁移指导
实现
AB 测试
业务方前期对接推荐使用 AB 测试,以限制影响。对于 API 的包装,提供 Demo 让业务方参考
收集网络错误数据(有了降级逻辑后,错误数很可能增加,因为一个请求可能发送多次)和业务错误数据对比分析,具体还要再研究
补记
在 NetProvider 内部尝试了 Cronet,但业务方集成后发现其与 Thanos 的配合有下述问题:
目前就将 Cronet 从 Quicksilver 中移除,避开 Cronet 和 Thanos 不兼容的问题,也就没有步骤 D 了。
参考链接