Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS 支持 SNI 方案调研 #15

Open
yuhanle opened this issue Dec 13, 2020 · 0 comments
Open

iOS 支持 SNI 方案调研 #15

yuhanle opened this issue Dec 13, 2020 · 0 comments
Assignees

Comments

@yuhanle
Copy link
Owner

yuhanle commented Dec 13, 2020

image

SNI 定义

参考维基百科:服务器名称指示

服务器名称指示(英语:Server Name Indication,缩写:SNI)是TLS的一个扩展协议[1],在该协议下,在握手过程开始时客户端告诉它正在连接的服务器要连接的主机名称。这允许服务器在相同的IP地址TCP端口号上呈现多个证书,并且因此允许在相同的IP地址上提供多个安全(HTTPS)网站(或其他任何基于TLS的服务),而不需要所有这些站点使用相同的证书。它与HTTP/1.1基于名称的虚拟主机的概念相同,但是用于HTTPS。所需的主机名未加密,[2] 因此窃听者可以查看请求的网站。

重要:按照 Apple 的说法,它支持 SNI,只不过是 Host name 形式的,参考:https://forums.developer.apple.com/thread/105057。只不过之前 Apple 也确反对过直接使用 IP 地址,所以在 URLSession 这一层没有任何关于 SNI 的接口。

Is there anything that iOS client side needs to implement in order to comply with SNI-enabled server.

No. There’s also no customisation for this. NSURLSession always uses the host name from the URL for 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 查询。

问题概述

  1. 客户端为对抗 DNS 污染,开启了 HTTPDNS,这样在目标请求发起前,URL 里的 host 会被 IP 地址替换。
  2. 对于 http 协议,需要在请求的 Header 里附带 Host 字段,这样服务器才能用正确的服务响应。
  3. 非 SNI,对于 https 协议,在 SSL/TLS 握手过程中,服务端下发的证书里的 CN 字段(即证书颁发的域名)仍然为域名的形式。这时客户端在做证书校验时,就会出现 domain 不匹配的情况,导致 SSL/TLS 握手不成功。
  4. SNI,对于 https 协议,在 SSL/TLS 握手过程中,由于服务器配置了多个域名的 SSL 证书,在握手发送证书时,不知道客户端访问的是哪个域名(注意这时是 IP 作为 host),所以无法根据不同域名发送不同的证书。

对于单 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 参数):

NSURL *url = ...;
NSString *ip = _ipOfHost(url.host); // 利用某种方式得到 IP,比如 HTTPDNS
struct curl_slist *hosts_list = NULL;
NSString *port = [url.scheme isEqualToString:@"https"] ? @"443" : @"80";
NSString *hostWithIP = [[NSString alloc] initWithFormat:@"%@:%@:%@", url.host, port, ip];
hosts_list = curl_slist_append(hosts_list, hostWithIP.UTF8String);
curl_easy_setopt(curl, CURLOPT_RESOLVE, hosts_list);

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++ 实现,理论上可以跨平台,不过已超出我的能力范围,未研究。

结论

  1. 没有完美且简单的办法。
  2. 理论上,对于 https,上关掉 HTTPDNS 肯定可以避免我们遇到 SNI 问题;对于 http,应该可以继续使用 HTTPDNS,因为它本质上没有校验。(分地区处理?)
  3. 在 https 上继续使用 HTTPDNS,需实现一个支持 IP SNI 的网络库。
  4. 目前无论哪种方法,都不能干预 WKWebView 里的请求,只能利用 jsBridge,…… 不过理论上 WKWebView 里的请求不会有 HTTPDNS 的参与。

后续

  1. 基于 curl 实现一个网络库,再让 Quicksilver 集成它,并提供配置接口,可在之前的实现和基于 curl 的实现之间切换。这样对于业务方更友好,除了配置,不用或者少量修改业务代码。
  2. 或者研究 CURL + URLProtocol,一样在 Quicksilver 里利用 URLProtocol 拦截 https 请求,再用 curl 来执行具体请求,一样需要对付 URLProtocol 方面的问题。

问题的本质是 HTTPDNS + HTTPS + SNI 三者同时存在,Apple 明确反对直接使用 IP 请求,因此 iOS 不支持 IP 形式的 SNI。

方案

  1. 使用两个域名,其中一个 https 无 CDN 开 HTTPDNS,记为 A,另一个 https 有 CDN 但不开 HTTPDNS,记为 B。我们可以先使用 A 请求数据,在失败时,再使用 B 请求数据。或者根据用户地区的不同,先 B,失败再 A。也可以做成配置来调整。
  2. 组建专门的网络 team,使用 Chrome's net,搭建跨平台的网络库,专门对付各种网络问题。

小背景

假定外部网络条件在大多数情况下都是正常的,少数情况下,例如某些运营商在特定时段才有 DNS 污染,而且网络链路中不稳定的节点可能小概率随机出现
服务端有动态 CDN 版本的域名,也就是有两个域名

方案

基于当前网络库,设计其内部降级方案(其中一种策略):

  • A:使用默认配置(不使用 HTTPDNS,不使用动态 CDN)发起网络请求
  • B:在 A 失败时,开启 HTTPDNS,再重新发送请求
  • C:在 B 失败时,切换请求域名到动态 CDN 的版本,并关闭 HTTPDNS,再重新发送请求

如果我们在网络库中集成 Cronet,我们就可以增加第 D 步:

  • D:在 C 失败时,依然适用动态 CDN 版本的域名,开启 HTTPDNS,使用 Cronet 再重新发送请求
    或者,直接将第 C 步改为:

  • C:在 B 失败时,切换请求域名到动态 CDN 的版本,开启 HTTPDNS,使用 Cronet 再重新发送请求

如图:

image

无论哪种方案,都不能解决 WKWebView 里的问题。

网络库改造

原则

尽量少的修改 API,提供迁移指导

实现

  • 为了减少对已有设计的冲击和保持 API 兼容性,可设计新的 NetProvider 来包装降级逻辑,以区别原来的 QuicksilverProvider,但保持 plugin 机制
  • 设计几种降级策略(例如默认开 HTTPDNS,因为它有加速查询的作用),可根据网络情况自动选择,或允许业务开发者指定(例如海外业务优先使用动态 CDN)
  • 对于错误的类型可能需要区分,例如用户网络不可用,以减少不必要的重试

AB 测试

业务方前期对接推荐使用 AB 测试,以限制影响。对于 API 的包装,提供 Demo 让业务方参考

收集网络错误数据(有了降级逻辑后,错误数很可能增加,因为一个请求可能发送多次)和业务错误数据对比分析,具体还要再研究

补记

在 NetProvider 内部尝试了 Cronet,但业务方集成后发现其与 Thanos 的配合有下述问题:

  • 网络请求没有回调(在 Thanos 内做过 hack,强持有 proxyObject,在其 demo 中,lazy var net = NetProvider... 可以收到回调,但 let net = NetProvider... 则无法收到回调)
  • 业务方集成后发现有 Crash(调查发现原因是 Thanos 所需要的 NSURLSessionTaskMetrics 在 Cronet 内部以 CronetMetrics 实现,但其并未遵守其属性约定,导致 Thanos 访问其 taskInterval 和 redirectCount 时发生崩溃。虽然有 Workaround,但会导致 Thanos 收集的数据不可靠)
  • 业务方集成后网络请求无法回调(初步怀疑是业务方引入更多第三方库,它们内部可能也有一些 hack 机制,导致消息转发过程被破坏)

目前就将 Cronet 从 Quicksilver 中移除,避开 Cronet 和 Thanos 不兼容的问题,也就没有步骤 D 了。

参考链接

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant