HTTPDNS在iOS端的实践

DNS解析本质上是localDNS的解析,说白了,你给它一个域名,它返回给你一个IPlist

DNS(Domain Name System)域名解析系统,这个东西说对于开发者来说,应该是没有不知道的。说简单点,这个系统的作用就是将域名解析成IP地址。我们的每一次网络请求,如果是使用域名,那么就是进行域名解析。
一个优秀的域名服务应该能够满足两点要求

  • 能够正确的返回IP地址
  • 就是能够根据网络情况返回所请求的域名最近的服务器IP

一: DNS解析路线

DNS劫持和故障.png

LocalDNS
一个DNS查询,会先从本地缓存查找,如果没有或者已经过期,就从DNS服务器查询,如果客户端没有主动设置DNS服务器,一般是从服务商DNS服务器上查找。这就出现了不可控。因为如果使用了IPS的LocalDNS域名服务器,那么基本都会或多或少地无法避免在有中国特色的互联网环境中遭遇到各种域名被缓存、用户跨网访问缓慢等问题。
我们先来看看普通域名服务会有什么问题:

  • 1. 域名劫持
    一些小服务商以及小地方的服务商非常喜欢干这个事情。根据腾讯给出的数据,DNS劫持率7%,恶意劫持率2%。网速给的劫持率是10-15%。

    • 把你的域名解析到竞争对手那里,然后哭死都不知道,为什么流量下降了。
    • 在你的代码当中,插入广告或者追踪代码。这就是为什么在淘宝或者百度搜索一下东西,很快就有人联系你。
    • 下载APK文件的时候,替换你的文件,下载一个其他应用或者山寨应用。
    • 打开一个页面,先跳转到广告联盟,然后跳转到这个页面。无缘无故多花广告钱,以及对运营的误导。
  • 2.智能DNS策略失效

智能DNS,就是为了调度用户访问策略,但是这些因素会导致智能DNS策略失效。

  • 小运营商,没有DNS服务器,直接调用别的服务商,导致服务商识别错误,直接跨网传输,速度大大下降。
  • 服务商多长NAT,实际IP,获得不了,结果没有就近访问。
  • 一些运营商将IP设置到开卡地,即使漫游到其他地方,结果也是没有就近访问。

目前国内大多数企业对于域名解析这块问题没有进行特殊处理,这导致了上述说的那些问题,其中域名劫持的问题相当普遍。那么有没有一种方法能够避免上述的情况呢?有,当然有。那就是使用HTTPDNS
HttpDNS其实也是对DNS解析的另一种实现方式,只是将域名解析的协议由DNS协议换成了Http协议,并不复杂。使用HTTP协议向D+服务器的80端口进行请求,代替传统的DNS协议向DNS服务器的53端口进行请求,绕开了运营商的Local DNS,从而避免了使用运营商Local DNS造成的劫持和跨网问题。
接入HttpDNS也是很简单的,使用普通DNS时,客户端发送网络请求时,就直接发送出去了,有底层网络框架进行域名解析。当接入HttpDNS时,就需要自己发送域名解析的HTTP请求,当客户端拿到域名对应的IP之后,就向直接往此IP发送业务协议请求。
这样,就再也不用再考虑传统DNS解析会带来的那些问题了,因为是使用HTTP协议,所以不用担心域名劫持问题了;而且,如果选择好的DNS服务器提供商,还保证将用户引导的访问速度最快的IDC节点上。
HTTPDNS是使用http请求替换域名解析的过程,但一般这个http请求都是基于https的,且是IP直连的,这样我们就保证了,这个解析域名的http请求不会被劫持并且内容安全.

二:HTTPDNS服务

我们一般在客户端上做HTTPDNS服务的解决方案的时候,策略可简单可复杂,但大体要围绕以下几个问题:

  • 1.数据要预先获取
  • 2.运营商变化的时候要更新数据
  • 3.为了提升获取ip的成功率要有过期数据的预取策略
  • 4.当由于种种原因,获取不到HTTPDNSIP时,要降级为获取localDNSIP

iOS网络库没有Android的网络库订制的那么深度,其实就是AFNetworkingokhttp的差距,所以针对HTTPDNS的网路库适配(才是今天的最佳实践)iOS会更加原始一些,它处理的问题大致有以下几类

  • 域名替换IP,防止劫持的关键就是不采用域名请求,取而代之的是IP直连

  • ** https的处理,由于替换了IPhttps可信任域名检验机制获取不到域名**

  • HTTP Proxy的处理,当iOS网络切换成HTTP代理后,由于替换了IP会导致连接失败,一般的处理方式是关闭HTTPDNS服务

  • Cookie的处理,由于替换了IP,导致Cookiedomain获取不到,从而使Cookie失效

  • IPV6的处理,苹果在2016年强制推行的IPV6,由于我们切换成IP直连的方式,所以会在iOS8.4以下的版本,IPV6-only的网络环境下连接失败,苹果官方的建议采用getAddressInfo方法解决这个问题,但这个方法在iOS9.2后才支持将一个IPV4的IP转成一个IPV6的IP,所以我们一般会在这种情况下降级

  • UIWebView/WKWebView的处理,不管是哪种WebView,我们都可以采用苹果的黑科技NSURLProtocol进行网络层面的拦截,从而接管WebView的网络能力,从而支持WebViewDNS反劫持

HTTPDNS解析.png

iOS端的网络层是基于AFNetworking进行封装实现的,iOS端的网络框架NSURLSession没有提供DNS解析相关的接口供使用者进行自定义修改DNS解析结果,因此在iOS端接入HTTPDNS有几个通用的问题需要处理,如请求的URL的域名替换为IP地址、请求头中设置原始HOST、SSL证书校验处理、Cookie问题处理、重定向、SNI场景下的问题处理,以及对应的SNI场景下的数据编解码和链接复用等问题,上述这些问题都需要有一个统一的解决方案。

因此,我们在腾讯云HTTPDNS的SDK作为提供HTTPDNS的基础能力之上,单独封装了iOS端HTTPDNS的接入层SDK,主要用来实现一些定制的策略和解决上述问题,同时也方便后续更换SDK或者接入自部署的HTTPDNS方案,让上层各业务方能够无感知底层HTTPDNS服务的存在,减少业务入侵性。

iOS端接入层SDK架构图如下图所示:


SDK.png
接口层

接口层提供的部分接口:

// 开启HTTPDNS服务
- (void)startHTTPDNS;

// 白名单列表,如果设置了白名单,则只有在白名单内域名走httpdns服务
@property (nonatomic, copy) NSArray *whiteDomainList;

// 黑名单列表,如果设置了黑名单,黑名单内域名都不走httpdns,黑名单的优先级最高
@property (nonatomic, copy) NSArray *blackDomainList;

// 是否允许缓存ip,允许缓存的情况下,在通过第三方服务无法获取ip的情况下,允许使用上次解析成功的ip进行请求,默认YES
@property (nonatomic, assign) BOOL enableCachedIP;
策略层

策略层主要提供不同的策略组合和配置,能够使得SDK能够稳定的对外提供HTTPDNS服务,下面简单介绍一下每个策略的内容:
容灾策略SDK内部优先使用HTTPDNS服务,当HTTPDNS服务不可用时,即无法获得有效ip时,服务自动降级为运营商的LocalDNS服务,确保不受HTTPDNS服务不可用时导致系统故障无法发出网络请求。注:目前阶段没有接入内置ip策略,后续会考虑
黑白名单策略:APP内的网络请求域名众多,目前并不是所有的网络请求都走HTTPDNS服务,设置了白名单或者黑名单后,会根据黑白名单中的域名去执行HTTPDNS,如果设置了白名单,则只有白名单内的域名走HTTPDNS服务;如果设置了黑名单,黑名单内的域名不走HTTPDNS服务,黑名单的优先级高于白名单。
缓存策略缓存策略除了基础服务层中腾讯云HTTPDNS SDK提供的基于TTL的缓存策略外,我们自己封装的接入层SDK中还存在一份内存缓存和本地化持久缓存,持久化缓存主要用来解决启动APP时无法获取HTTPDNS中的IP的问题,内存缓存主要为查询策略提供服务。当某个基于HTTPDNS的IP地址导致请求失败后,会清除当前域名和IP的缓存数据。同时外部可控制是否使用缓存。
查询策略查询策略主要是为了解决,短时间内同一个域名多次调用基础服务层的域名查询服务,当状态是正在查询中时,后来者不再调用查询服务,直接从缓存策略中的内存缓存中读取可用的IP,如果缓存内也无可用的IP,则直接降级为运营商的LocalDNS查询。查询策略可在确保服务可用的同时,有效减少和HTTPDNS服务器交互的次数。

注入层

注入层在iOS端是依赖NSURLProtocol进行拦截网络请求,在这里不再具体介绍NSURLProtocol的用法。基于NSURLProtocol拦截网络请求,我们分别实现了两套方案,在不需要处理SNI场景的情况下,基于NSURLSession实现;在需要处理SNIServer Name Indication,单IP多HTTPS证书)场景的情况下,基于CFNetwork实现。下面我们看一下两种方案:

  • (1)SNI场景下基于CFNetwork的实现方案
    SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的SSL/TLS扩展。它的工作原理如下:
  • 在连接到服务器建立SSL链接之前先发送要访问站点的域名(Hostname)。

  • 服务器根据这个域名返回一个合适的证书。

上述过程中,当客户端使用HTTPDNS解析域名时,请求URL中的host会被替换成HTTPDNS解析出来的IP,导致服务器获取到的域名为解析后的IP,无法找到匹配的证书,只能返回默认的证书或者不返回,所以会出现SSL/TLS握手不成功的错误。

由于iOS上层网络库NSURLSession没有提供接口进行SNI字段的配置,因此可以考虑使用NSURLProtocol拦截网络请求,然后使用CFHTTPMessageRef创建NSInputStream实例进行Socket通信,并设置其kCFStreamSSLPeerName的值。

注:上述文字来自于腾讯HTTPDNS官方文档。

基于CFHTTPMessageRefNSInputStream设置SNI关键代码如下:


 // 设置SNI host信息
NSString *host = [self.sniRequest.allHTTPHeaderFields objectForKey:@"Host"];
if (!host) {
    host = self.originalRequest.URL.host;
}
[self.inputStream setProperty:NSStreamSocketSecurityLevelNegotiatedSSL forKey:NSStreamSocketSecurityLevelKey];
NSDictionary *sslProperties = @{ (__bridge id) kCFStreamSSLPeerName : host };
[self.inputStream setProperty:sslProperties forKey:(__bridge_transfer NSString *) kCFStreamPropertySSLSettings];

基于CFNetwork的实现方案,除了设置SNI信息外,还需要考虑的数据编解码的问题,在我们看到的众多的开源代码和文章中很少有人提及这一点,因此我们在处理响应数据时需要添加类似如下代码进行响应数据的解码操作:

//检查`Content-Encoding`,返回数据是否需要进行解码操作;
//此处仅做了gzip解码的处理,业务场景若确定有其他编码格式,需自行完成扩展。
NSString *contentEncoding = [self.response.headerFields objectForKey:@"Content-Encoding"];
if (contentEncoding && [contentEncoding isEqualToString:@"gzip"]) {
    [self.delegate task:self didReceiveData:[self ungzipData:self.resultData]];
} else {
    [self.delegate task:self didReceiveData:self.resultData];
}

此外还有非常重要的一点,基于CFNetwork的实现方案,需要考虑连接复用的问题,不能每次请求都重新创建,重新连接的成本非常高。这也是我们在看开源代码和文章从来不会提及的部分,如果此处不处理,性能消耗非常严重。

尤其我们目前大部分请求都已经是HTTP2.0(也就是H2)了,性能对比会更加明显。但由于苹果的CFNetwork框架是不支持HTTP2.0的,也就是我们很难基于CFNetwork实现到HTTP2.0的相关特性。我们目前是实现了HTTP1.1协议中连接复用这一部分功能,不需要每次请求都重新建立连接。

基本原理为相同host、port、scheme的请求,在请求发起时如果有可用的没过期的连接可以复用,就不需要重新建立连接,直接复用连接即可,如果连接在本地过期,或者服务端通过响应头主动关闭连接,则连接不复用,进行连接关闭。判断服务端是否连接复用,可通过响应头的Connectionkeep-alive还是close进行判断。

  • (2)非SNI场景下基于CFNetwork的实现方案
    基于NSURLSession的实现比较简单,在通过NSURLProtocol进行拦截请求后,只需要将Request中的域名替换成IP,在请求头中设置原始Host字段和Cookie字段,重新构建dataTask任务,发起请求即可,简单的示例代码如下:
//处理url和host dnsResultURL为替换ip后的URL
NSMutableURLRequest *ipRequest = [originRequest mutableCopy];
ipRequest.URL = [NSURL URLWithString:dnsResultURL];
[ipRequest setValue:url.host forHTTPHeaderField:@"Host"];
            
//处理cookie,由于url变了,系统并不会携带原域名下的cookie
NSString *cookieString = [[MFSNICookieManager sharedManager] requestCookieHeaderForURL:url];
[ipRequest setValue:cookieString forHTTPHeaderField:@"Cookie"];
            
self.ipRequest = ipRequest;
self.clientThread = [NSThread currentThread];
self.ipTask = [[[self class] sharedDemux] dataTaskWithRequest:ipRequest delegate:self modes:self.modes];
                        
if(self.ipTask){
    [self.ipTask resume];
}

HTTPS的证书校验流程中,由于我们修改了请求URL中的Host为IP地址,因此证书验证流程无法通过,因此需要修改证书的验证流程,在证书验证时,将IP替换为原来的域名,再进行证书验证。示例代码如下:

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *_Nullable))completionHandler {
    if (!challenge) {
        return;
    }
    NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    NSURLCredential *credential = nil;
    
    //获取原始域名host,用原始请求即可获取
    NSString *host = [[self.originRequest allHTTPHeaderFields] objectForKey:@"Host"];
    if (!host) {
        host = self.originRequest.URL.host;
    }
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
        if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
            disposition = NSURLSessionAuthChallengeUseCredential;
            credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
        } else {
            disposition = NSURLSessionAuthChallengePerformDefaultHandling;
        }
    } else {
        disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    }
    // 对于其他的challenges直接使用默认的验证方案
    completionHandler(disposition, credential);
}


- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain {
    //创建证书策略
    NSMutableArray *policies = [NSMutableArray array];
    if (domain) {
        [policies addObject:(__bridge_transfer id) SecPolicyCreateSSL(true, (__bridge CFStringRef) domain)];
    } else {
        [policies addObject:(__bridge_transfer id) SecPolicyCreateBasicX509()];
    }
    //绑定校验策略到服务端的证书上
    SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef) policies);
    /*
     * 评估当前serverTrust是否可信任,
     * 官方建议在result = kSecTrustResultUnspecified 或 kSecTrustResultProceed
     * 的情况下serverTrust可以被验证通过,https://developer.apple.com/library/ios/technotes/tn2232/_index.html
     * 关于SecTrustResultType的详细信息请参考SecTrust.h
     */
    SecTrustResultType result;
    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Wdeprecated-declarations"
    SecTrustEvaluate(serverTrust, &result);
    #pragma clang diagnostic pop
    return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
}

基础服务层

基础服务层目前阶段主要依赖腾讯云HTTPDNS SDK提供基础查询服务,主要提供基于TTL的缓存存储和过期处理逻辑,同时这一层还提供SDK内部缓存存储以及日志和基础校验等功能;

❗ ❗ ❗ ❗ ❗ ❗如果你对性能有这很高的要求,同时又需要处理SNI场景的问题,我建议不要直接主动使用HTTPDNS,而是在运营商LocalDNS获取的IP请求失败的情况下,可以在底层直接使用基于CFNetwork的网络请求进行重试,这样就能在请求DNS劫持性能中间得到一个平衡,既能保证在运营商的LocalDNS解析出现问题时能够走HTTPDNS,保证成功率和可用性;同时又能够在运营商的LocalDNS可用时,使用基于NSURLSession的请求,享受系统实现的HTTP2.0特性带来的性能提升。如果,不需要处理SNI的问题,就老老实实使用基于NSURLSession的实现方案

你可能感兴趣的:(HTTPDNS在iOS端的实践)