概述
SNI(单IP多HTTPS证书)场景下,iOS上层网络库 NSURLConnection/NSURLSession
没有提供接口进行 SNI 字段
配置,因此需要 Socket 层级的底层网络库例如 CFNetwork
,来实现 IP 直连网络请求
适配方案。而基于 CFNetwork 的解决方案需要开发者考虑数据的收发、重定向、解码、缓存等问题(CFNetwork是非常底层的网络实现)。
针对 SNI 场景的方案, Socket 层级的底层网络库,大致有两种:
- 基于 CFNetWork ,hook 证书校验步骤。
- 基于原生支持设置 SNI 字段的更底层的库,比如 libcurl。
下面将目前面临的一些挑战,以及应对策略介绍一下:
支持 Post 请求
使用 NSURLProtocol 拦截 NSURLSession 请求丢失 body,故有以下几种解决方法:
方案如下:
- 换用 NSURLConnection
- 将 body 放进 Header 中
- 使用 HTTPBodyStream 获取 body,并赋值到 body 中
- 换用 Get 请求,不使用 Post 请求。
对方案做以下分析
- 换用 NSURLConnection ,不多说了,与 NSURLSession 相比终究会被淘汰,不作考虑。
- body放header的方法,2M以下没问题,超过2M会导致请求延迟,超过 10M 就直接 Request timeout。而且无法解决 Body 为二进制数据的问题,因为Header里都是文本数据。
- 换用 Get 请求,不使用 Post 请求。这个也是可行的,但是毕竟对请求方式有限制,终究还是要解决 Post 请求所存在的问题。如果是基于旧项目做修改,则侵入性太大。这种方案适合新的项目。
- 另一种方法是我们下面主要要讲的,使用 HTTPBodyStream 获取 body,并赋值到 body 中,具体的代码如下,可以解决上面提到的问题:
// // NSURLRequest+NSURLProtocolExtension.h // // // Created by ElonChan on 28/07/2017. // Copyright © 2017 ChenYilong. All rights reserved. // #import <Foundation/Foundation.h> @interface NSURLRequest (NSURLProtocolExtension) - (NSURLRequest *)httpdns_getPostRequestIncludeBody; @end // // NSURLRequest+NSURLProtocolExtension.h // // // Created by ElonChan on 28/07/2017. // Copyright © 2017 ChenYilong. All rights reserved. // #import "NSURLRequest+NSURLProtocolExtension.h" @implementation NSURLRequest (NSURLProtocolExtension) - (NSURLRequest *)httpdns_getPostRequestIncludeBody { return [[self httpdns_getMutablePostRequestIncludeBody] copy]; } - (NSMutableURLRequest *)httpdns_getMutablePostRequestIncludeBody { NSMutableURLRequest * req = [self mutableCopy]; if ([self.HTTPMethod isEqualToString:@"POST"]) { if (!self.HTTPBody) { uint8_t d[1024] = {0}; NSInputStream *stream = self.HTTPBodyStream; NSMutableData *data = [[NSMutableData alloc] init]; [stream open]; while ([stream hasBytesAvailable]) { NSInteger len = [stream read:d maxLength:1024]; if (len > 0 && stream.streamError == nil) { [data appendBytes:(void *)d length:len]; } } req.HTTPBody = [data copy]; [stream close]; } } return req; } @end
AI 代码解读
使用方法:
在用于拦截请求的 NSURLProtocol
的子类中实现方法 +canonicalRequestForRequest:
并处理 request
对象:
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { return [request httpdns_getPostRequestIncludeBody]; }
AI 代码解读
下面介绍下相关方法的作用:
//NSURLProtocol.h /*! @method canInitWithRequest: @abstract This method determines whether this protocol can handle the given request. @discussion A concrete subclass should inspect the given request and determine whether or not the implementation can perform a load with that request. This is an abstract method. Sublasses must provide an implementation. @param request A request to inspect. @result YES if the protocol can handle the given request, NO if not. */ + (BOOL)canInitWithRequest:(NSURLRequest *)request; /*! @method canonicalRequestForRequest: @abstract This method returns a canonical version of the given request. @discussion It is up to each concrete protocol implementation to define what "canonical" means. However, a protocol should guarantee that the same input request always yields the same canonical form. Special consideration should be given when implementing this method since the canonical form of a request is used to look up objects in the URL cache, a process which performs equality checks between NSURLRequest objects. <p> This is an abstract method; sublasses must provide an implementation. @param request A request to make canonical. @result The canonical form of the given request. */ + (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
AI 代码解读
翻译下:
//NSURLProtocol.h /*! * @method:创建NSURLProtocol实例,NSURLProtocol注册之后,所有的NSURLConnection都会通过这个方法检查是否持有该Http请求。 @parma : @return: YES:持有该Http请求NO:不持有该Http请求 */ + (BOOL)canInitWithRequest:(NSURLRequest *)request /*! * @method: NSURLProtocol抽象类必须要实现。通常情况下这里有一个最低的标准:即输入输出请求满足最基本的协议规范一致。因此这里简单的做法可以直接返回。一般情况下我们是不会去更改这个请求的。如果你想更改,比如给这个request添加一个title,组合成一个新的http请求。 @parma: 本地HttpRequest请求:request @return:直接转发 */ + (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest *)request
AI 代码解读
简单说:
+[NSURLProtocol canInitWithRequest:]
负责筛选哪些网络请求需要被拦截+[NSURLProtocol canonicalRequestForRequest:]
负责对需要拦截的网络请求NSURLRequest
进行重新构造。
这里有一个注意点:+[NSURLProtocol canonicalRequestForRequest:]
的执行条件是 +[NSURLProtocol canInitWithRequest:]
返回值为 YES
。
注意在拦截 NSURLSession
请求时,需要将用于拦截请求的 NSURLProtocol 的子类添加到 NSURLSessionConfiguration
中,用法如下:
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; NSArray *protocolArray = @[ [CUSTOMEURLProtocol class] ]; configuration.protocolClasses = protocolArray; NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
AI 代码解读
换用其他提供了SNI字段配置接口的更底层网络库
如果使用第三方网络库:curl, 中有一个 -resolve
方法可以实现使用指定 ip 访问 https 网站,iOS 中集成 curl 库,参考 curl文档 ;
另外有一点也可以注意下,它也是支持 IPv6 环境的,只需要你在 build 时添加上 --enable-ipv6
即可。
curl 支持指定 SNI 字段,设置 SNI 时我们需要构造的参数形如: {HTTPS域名}:443:{IP地址}
假设你要访问. www.example.org ,若IP为 127.0.0.1 ,那么通过这个方式来调用来设置 SNI 即可:
curl * --resolve 'www.example.org:443:127.0.0.1'
iOS CURL 库
使用libcurl 来解决,libcurl / cURL 至少 7.18.1 (2008年3月30日) 在 SNI 支持下编译一个 SSL/TLS 工具包,curl
中有一个 --resolve
方法可以实现使用指定ip访问https网站。
在iOS实现中,代码如下
//{HTTPS域名}:443:{IP地址} NSString *curlHost = ...; _hosts_list = curl_slist_append(_hosts_list, curlHost.UTF8String); curl_easy_setopt(_curl, CURLOPT_RESOLVE, _hosts_list);
AI 代码解读
其中 curlHost
形如:
{HTTPS域名}:443:{IP地址}
_hosts_list
是结构体类型hosts_list
,可以设置多个IP与Host之间的映射关系。curl_easy_setopt
方法中传入CURLOPT_RESOLVE
将该映射设置到 HTTPS 请求中。
这样就可以达到设置SNI的目的。
走过的弯路
误以为 iOS11 新 API 可以直接拦截 DNS 解析过程
参考:NEDNSProxyProvider:DNS based on HTTP supported in iOS11
参考链接:
- Apple - Communicating with HTTP Servers
- Apple - HTTPS Server Trust Evaluation - Server Name Failures
- Apple - HTTPS Server Trust Evaluation - Trusting One Specific Certificate
- [《HTTPDNS > 最佳实践 > HTTPS(含SNI)业务场景“IP直连”方案说明
HTTPS(含SNI)业务场景“IP直连”方案说明》] - 《在 curl 中使用指定 ip 来进行请求 https》
- 支持SNI与WebView的 alicloud-ios-demo
- 《SNI: 实现多域名虚拟主机的SSL/TLS认证》