HTTPS IP直连问题小结

背景

任何一个移动APP项目都离不开网络接入功能,提升网络接入的质量几乎是所有移动项目的需求。很多项目都会引入HTTP DNS作为网络接入最基础也是最重要的优化之一。HTTP DNS的核心是后台下发某个域名对应的最优IP,基础点的可做到就近接入,即下发该域名终端就近地同运营商的IP,好一点的则根据线上用户实际测速数据下发最优的IP。而终端只需在HTTP接入时,将URL中的HOST从域名直接替换为后台下发的IP即可。
IP直连相对于域名接入的好处有:

  • 省去DNS解析这一步,减少耗时
  • 就近接入甚至就快接入,减少耗时
  • 避免DNS劫持
  • 当终端有多个IP接入选择时,有一定容灾能力

接入时后端常常都会存在转发层做一些诸如负载均衡的工作,由转发层将请求分发到真实服务器。HTTP接入的转发层一般是根据请求头中的HOST字段来转发到各个RS。所以对于HTTP接入,使用IP直连时需要设置一个HOST请求头,值为请求域名,在绝大部分情况下就可以获得上面提到的IP直连的好处。
但是对于HTTPS接入,情况会变得稍微复杂一些。本文主要是对Android平台使用HttpURLConnection(SDK开发由于包大小的限制,HTTP接入基本只能用这个了…)进行HTTPS IP直连时,遇到的一些问题及其解决方法的小结。


HTTPS IP直连问题与解决

首先先用一张图回顾下HTTPS接入的大致流程
HTTPS IP直连问题小结_第1张图片
本文描述的问题都发生在服务端公钥证书的校验这一步。

问题1. 证书HOST校验问题

终端在SSL握手过程会校验当前请求URL的HOST是否在服务端证书的可选域名列表里。举个例子,假设原本想要请求的URL为https://v.html5.qq.com,而使用IP直连后实际请求的URL为:https://183.61.38.230:443。此时服务端返回的证书可选域名列表如下
HTTPS IP直连问题小结_第2张图片
由于请求的HOST被替换成了IP,导致底层在进行证书的HOST校验时失败,最终请求失败。

这个问题的解决还比较简单。系统提供了接口,允许终端设置证书HOST校验实现。所以直接将底层默认实现中取终端传入URL的HOST(此处即IP)替换回,IP直连替换前的域名即可。
HTTPS IP直连问题小结_第3张图片

问题2. SNI问题

解决了问题1后,请求可以成功是“纯属意外”。事实上,183.61.38.230:443这个转发层部署了多个域名的证书,除了问题1中的v.html5.qq.com,还有https://ag.qq.com等域名。此时如果用https://ag.qq.com进行IP直连,请求会失败,因为当终端使用IP直连时,服务端SSL握手阶段获取到的域名为调度后的IP,服务端无法找到匹配的证书,只能返回默认的证书或者不返回。喵喵问题1中的图,默认返回的证书的拓展域名列表是不包含ag.qq.com的,所以证书的HOST校验还是会失败,导致请求失败。

这个问题的解决也并不复杂:

  1. 系统提供接口,允许终端传入自定义SSLSocketFactory。SSLSocketFactory是创建SSLSocket的工厂,SSLSocket是Socket拓展,有SSL握手功能。
  2. 系统提供解决SNI问题的实现类SSLCertificateSocketFactory

所以设置一个自定义SSLSocketFactory并代理SSLCertificateSocketFactory即可解决SNI问题:
HTTPS IP直连问题小结_第4张图片

SSLSocketFactory接口有很多个创建Socket的方法,但是底层回调的就是Socket createSocket(Socket s, String host, int port, boolean autoClose),后文也会提到。

进行了这一步操作后,对https://ag.qq.com进行IP直连也可以成功了。因为SSL握手时,会在SNI拓展字段中传入实际请求域名:
HTTPS IP直连问题小结_第5张图片
而服务端也能返回正确的非默认的证书了:
HTTPS IP直连问题小结_第6张图片

问题3. 连接复用问题

解决了证书HOST校验问题与SNI问题后,请求确实可以成功了,但是却不知不觉中引入了一个巨大的性能问题,即连接复用失效的问题。

在HTTP1.0时代,每一个请求都必须经过三步,即建立连接,请求响应数据,然后就断开连接了,下一次请求又重新建立连接。如果是HTTPS就更加糟糕,建立连接后还要多一个更加耗时耗资源的SSL握手过程。到了HTTP1.1时代,引入了连接复用,当CS/BS都支持连接复用时,握手后,会在同一个连接上不断收发数据,直到一方断开。

如果服务端支持连接复用,即响应头不带有Connection:close时,HttpURLConnection使用域名接入时,连接复用运作正常:
HTTPS IP直连问题小结_第7张图片
而使用IP直连时,连接复用却失效:
HTTPS IP直连问题小结_第8张图片
HTTPS IP直连问题小结_第9张图片

不解决这个问题,单从耗时来说,使用IP直连还不如直接使用域名,因为每次握手的耗时是非常明显的,所以需要研究下底层是怎么去进行连接复用的。

因为Android#HttpURLConnection从4.4开始就将底层实现切换到了OkHttp,所以可以直指矛头,直接分析OkHttp的源码(当时我找的是最新版本的OkHttp代码,因为感觉连接复用这一块是比较基础的代码应该不会有较大变动,其实每一个Android版本对应的OkHttp版本都不同)。

分析源码不可能面面俱到吃透每一个细节,摸清大致框架结构,根据问题去找切入点,很明显连接复用相关的代码就在ConnectIntercetor那一环:
HTTPS IP直连问题小结_第10张图片

沿着请求接口Debug追溯可知:

  1. 当一个请求连接建立完成后,会将这个连接缓存到连接池中;
  2. 一个新的请求过来时,会先判断连接池中是否已有可复用连接,有则复用,无则新建连接。

判断是否有可复用连接,就是遍历连接池中的缓存连接,每一个缓存连接都判断一下当前请求是否可用。
HTTPS IP直连问题小结_第11张图片
至此可知连接能否复用的条件就在RealConnection#isEligible()方法里。

看注释可以知道:底层判断连接可否复用并不仅需要判断HOST equals,还有一些参数也要equals。
HTTPS IP直连问题小结_第12张图片
我们解决问题1、2时设进来的两个对象赫然在列…
HTTPS IP直连问题小结_第13张图片
看下这个静态equal方法
这里写图片描述

连接复用底层要求,两个请求的HostnameVerifier对象要么是同一个对象,要么是两个相等的对象。为了防止同一个对象的内部数据结构有变化产生影响,采取重写equals方法使条件成立。SSLSocketFatcory同理。

public class MyHostnameVerifier implements HostnameVerifier{

    public String bizHost;

    public MyHostnameVerifier(String bizHost) {
        this.bizHost = bizHost;
    }

    @Override
    public boolean verify(String hostname, SSLSession session) {
        return HttpsURLConnection.getDefaultHostnameVerifier().verify(bizHost, session);;
    }

    @Override
    public boolean equals(Object o) {
        if (TextUtils.isEmpty(bizHost) || !(o instanceof MyHostnameVerifier)) {
            return false;
        }
        String thatHost = ((MyHostnameVerifier) o).bizHost;
        if (TextUtils.isEmpty(thatHost)) {
            return false;
        }
        return bizHost.equals(thatHost);
    }
}

两个请求传入相等的HostnameVerifier对象与SSLSocketFatcory对象后,HTTPS IP直连连接复用效果与使用域名接入一致了。

问题4. 兼容性问题

解决了上述三个问题,HTTPS使用IP直连应该是初见成效了,由于给底层设置了两个自定义的对象,而不同系统版本底层网络这块是有较大变化的,所以有必要进行一下兼容性测试。不测不知道,一测又吓一跳了。

当API小于23时,之前进行的SNI测试,即用https://ag.qq.com进行IP直连请求,又无法成功。但是使用阿里云的HTTPDNS+SNI参考实现,仍然成功。其实SNI问题的解决当时就是参考的阿里的方案,就是问题2中设置自定义SSLSocketFactory,不过他们在代理SSLCertificateSocketFactory时,调用的是这个接口(后文称接口1):
这里写图片描述
而本文的实现则是调用:Socket createSocket(Socket s, String host, int port, boolean autoClose)这个接口(后文称接口2)。

接口1它先用底层创建的Socket得到请求的IP地址,然后重新再创建一个SSLSocket,所以会多一次握手挥手过程。
HTTPS IP直连问题小结_第14张图片
而接口2则是直接复用先前底层创建好的Socket,不会多这一次握手挥手过程。
HTTPS IP直连问题小结_第15张图片
这里写图片描述
所以调用接口2效果更佳

而现在要分析的是,为什么API23以下,接口2会校验失败。当前调用链为:
SDK -> HttpsURLConnection -> OkHttp -> SDK(回调SDK传入的SSLSocketFactory) -> SSLCertificateSocketFactory(SDK代理此实现解决SNI问题) -> OpenSSL(最终的底层实现是OpenSSL库)

OkHttp层ConnectInterceptor有固定操作:

  1. 创建一个普通rawSocket
  2. 回调SDK层创建一个SSLSocket
    sslSocket = (SSLSocket) sslSocketFactory.createSocket(
    rawSocket, address.url().host(), address.url().port(), true);

SDK层操作:
在OkHttp的回调接口里调用SSLCertificateSocketFactory创建SSLSocket,并设置域名以解决SNI问题。

接口2比接口1多调用一个verifyHostname方法:
HTTPS IP直连问题小结_第16张图片
HTTPS IP直连问题小结_第17张图片

verifyHostname方法里是会进行握手和证书HOST校验的
HTTPS IP直连问题小结_第18张图片

当SSLCertificateSocketFactory调用接口2时,会在此类进行握手,并且此时并没有为SSLScoket设置域名,所以是不支持SNI的,证书HOST校验又一次失败…而API23以后,OpenSSL层的实现有变化,使得在SSLCertificateSocketFactory层的握手也支持SNI,HOST校验成功。

当SSLCertificateSocketFactory调用接口1时,SSL握手会在SDK层进行,握手前会为SSLSocket设置域名,所以可以支持SNI。

综上,最终实现为:
API<23:
SDK层使用SSLCertificateSocketFactory# createSocket(InetAddress addr, int port)接口,在SDK层握手,握手前设置SSLSocket域名,支持SNI,不过会多一次握手挥手过程。
API>=23:
SDK层使用SSLCertificateSocketFactory#createSocket(Socket k, String host, int port, boolean close)接口,在SSLCertificateSocketFactory层握手,默认支持SNI。

问题5. Session复用问题

SSL握手过程中,密钥协商是其中最耗资源和时间的过程,Session复用能节省协商的消耗。Session复用同样需要CS双方支持。

Session复用有两种方式:

  1. 通过Session ID
    SSL握手过后,服务端可以将协商后的信息存起来,生成 Session ID ,终端也可以保存 Session ID,并在后续的 Client Hello 握手中带上它,如果服务端能找到与之匹配的信息,就可以快速完成握手。

  2. 通过Session Ticket
    Session ID机制有一些弊端,例如:1)集群多机之间往往没有同步 Session ID信息;2)服务端存储的Session ID会不断增加,消耗内存等资源
    Session Ticket 是用只有服务端知道的安全密钥加密过的会话信息,最终保存在终端端。终端如果在 Client Hello 时带上了 Session Ticket,只要服务端能成功解密,就可以完成快速握手。

自定义SSLSocketFatroy在之前实现的基础上,为SSLCertificateSocketFactory对象添加系统对象SSLSessionCache,即可实现Session Ticket复用会话。

  1. 握手完成后服务端返回Session Ticket
    这里写图片描述

  2. 连接断开,再次SSL握手时,Client Hello携带Session Ticket。此时服务端不会下发证书,省去协商过程。
    HTTPS IP直连问题小结_第19张图片
    这里写图片描述

本来到此整个过程就结束了,但是直觉告诉我,还是要做一下兼容性测试…发现当API>=23,HTTPS IP直连时,不管是否设置SSLSessionCache,服务端均不会返回Session Ticket与Session ID。而使用域名接入时,则正常返回Session Ticket。底层实现变化,尚未查明原因~

最终优化效果

HTTPS IP直连问题小结_第20张图片


参考

http://km.oa.com/group/11879/articles/show/249476?kmref=search&from_page=1&no=1(HTTPS原理探讨)
https://help.aliyun.com/document_detail/30143.html?spm=5176.doc36009.6.146.CMlVCh(HTTPS(含SNI)业务场景“IP直连”方案说明)
https://imququ.com/post/optimize-tls-handshake.html(TLS 握手优化详解)
《图解HTTP》
https://android.googlesource.com/

最后附上源码
https://github.com/leelit/SdkSNISocketFactory


更新

问题4里面提到,SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(Socket k, String host, int port, boolean close)接口会多调用一个verifyHostname方法,而这个方法里是会进行握手和证书HOST校验的。所以导致了在SDK层对这个对sslSocket对象调用的setHostname方法解决SNI,以及设置sessionCache,实际上都是没有起到作用。
当使用SSLCertificateSocketFactory.getInsecure(10 * 1000, sslSessionCache)接口获取SSLCertificateSocketFactory实例时,createSocket(Socket k, String host, int port, boolean close)接口内部不会进行握手,当回到SDK层设置参数后再进行握手,就能解决SNI与Session复用问题了。

更换SSLCertificateSocketFactory实例获取接口后,HTTPS IP直连的效果与使用域名效果一致:
1. 全部版本支持SNI
2. API19以上均支持连接复用与SessionTicket,不产生任何多余的三次握手。

你可能感兴趣的:(Android网络编程)